chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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="<thinking></thinking>",
|
||||
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="<thinking></thinking>",
|
||||
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="<thinking>", type="text_delta"), index=0, type="content_block_delta"
|
||||
),
|
||||
TextEvent(type="text", text="<thinking>", snapshot="<thinking>"),
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=TextDelta(text="</thinking>", type="text_delta"), index=0, type="content_block_delta"
|
||||
),
|
||||
TextEvent(type="text", text="</thinking>", snapshot="<thinking></thinking>"),
|
||||
ContentBlockStopEvent(
|
||||
index=0, type="content_block_stop", content_block=TextBlock(text="<thinking></thinking>", 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="<thinking></thinking>", 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="<thinking></thinking>",
|
||||
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
|
||||
@@ -0,0 +1,549 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from anthropic import AsyncAnthropic
|
||||
from anthropic.types import Message
|
||||
|
||||
from semantic_kernel.connectors.ai.anthropic.prompt_execution_settings.anthropic_prompt_execution_settings import (
|
||||
AnthropicChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.anthropic.services.anthropic_chat_completion import AnthropicChatCompletion
|
||||
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import (
|
||||
OpenAIChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
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.exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
ServiceResponseException,
|
||||
)
|
||||
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.kernel import Kernel
|
||||
|
||||
|
||||
async def test_complete_chat_contents(
|
||||
kernel: Kernel,
|
||||
mock_settings: AnthropicChatPromptExecutionSettings,
|
||||
mock_chat_message_response: Message,
|
||||
):
|
||||
client = MagicMock(spec=AsyncAnthropic)
|
||||
messages_mock = MagicMock()
|
||||
messages_mock.create = AsyncMock(return_value=mock_chat_message_response)
|
||||
client.messages = messages_mock
|
||||
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("test_user_message")
|
||||
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=client
|
||||
)
|
||||
|
||||
content: list[ChatMessageContent] = await chat_completion_base.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=mock_settings, kernel=kernel, arguments=arguments
|
||||
)
|
||||
|
||||
assert len(content) > 0
|
||||
assert content[0].content != ""
|
||||
assert content[0].role == AuthorRole.ASSISTANT
|
||||
|
||||
|
||||
mock_message_text_content = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[TextContent(text="test")])
|
||||
|
||||
mock_message_function_call = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
FunctionCallContent(
|
||||
name="test",
|
||||
arguments={"key": "test"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"function_choice_behavior,model_responses,expected_result",
|
||||
[
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Auto(),
|
||||
[[mock_message_function_call], [mock_message_text_content]],
|
||||
TextContent,
|
||||
id="auto",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Auto(auto_invoke=False),
|
||||
[[mock_message_function_call]],
|
||||
FunctionCallContent,
|
||||
id="auto_none_invoke",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Required(auto_invoke=False),
|
||||
[[mock_message_function_call]],
|
||||
FunctionCallContent,
|
||||
id="required_none_invoke",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_complete_chat_contents_function_call_behavior_tool_call(
|
||||
kernel: Kernel,
|
||||
mock_settings: AnthropicChatPromptExecutionSettings,
|
||||
function_choice_behavior: FunctionChoiceBehavior,
|
||||
model_responses,
|
||||
expected_result,
|
||||
):
|
||||
kernel.add_function("test", kernel_function(lambda key: "test", name="test"))
|
||||
mock_settings.function_choice_behavior = function_choice_behavior
|
||||
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = AnthropicChatCompletion(ai_model_id="test_model_id", service_id="test", api_key="")
|
||||
|
||||
with (
|
||||
patch.object(chat_completion_base, "_inner_get_chat_message_contents", side_effect=model_responses),
|
||||
):
|
||||
response: list[ChatMessageContent] = await chat_completion_base.get_chat_message_contents(
|
||||
chat_history=ChatHistory(system_message="Test"), settings=mock_settings, kernel=kernel, arguments=arguments
|
||||
)
|
||||
|
||||
assert all(isinstance(content, expected_result) for content in response[0].items)
|
||||
|
||||
|
||||
async def test_complete_chat_contents_function_call_behavior_without_kernel(
|
||||
mock_settings: AnthropicChatPromptExecutionSettings,
|
||||
mock_anthropic_client_completion: AsyncAnthropic,
|
||||
):
|
||||
chat_history = MagicMock()
|
||||
chat_completion_base = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=mock_anthropic_client_completion
|
||||
)
|
||||
|
||||
mock_settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
|
||||
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
await chat_completion_base.get_chat_message_contents(chat_history=chat_history, settings=mock_settings)
|
||||
|
||||
|
||||
async def test_complete_chat_stream_contents(
|
||||
kernel: Kernel,
|
||||
mock_settings: AnthropicChatPromptExecutionSettings,
|
||||
mock_streaming_message_response,
|
||||
):
|
||||
client = MagicMock(spec=AsyncAnthropic)
|
||||
messages_mock = MagicMock()
|
||||
messages_mock.stream.return_value = mock_streaming_message_response
|
||||
client.messages = messages_mock
|
||||
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("test_user_message")
|
||||
|
||||
arguments = KernelArguments()
|
||||
|
||||
chat_completion_base = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id",
|
||||
service_id="test",
|
||||
api_key="",
|
||||
async_client=client,
|
||||
)
|
||||
|
||||
async for content in chat_completion_base.get_streaming_chat_message_contents(
|
||||
chat_history, mock_settings, kernel=kernel, arguments=arguments
|
||||
):
|
||||
assert content is not None
|
||||
|
||||
|
||||
mock_message_function_call = StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, items=[FunctionCallContent(name="test")], choice_index="0"
|
||||
)
|
||||
|
||||
mock_message_text_content = StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, items=[TextContent(text="test")], choice_index="0"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"function_choice_behavior,model_responses,expected_result",
|
||||
[
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Auto(),
|
||||
[[mock_message_function_call], [mock_message_text_content]],
|
||||
TextContent,
|
||||
id="auto",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Auto(auto_invoke=False),
|
||||
[[mock_message_function_call]],
|
||||
FunctionCallContent,
|
||||
id="auto_none_invoke",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Required(auto_invoke=False),
|
||||
[[mock_message_function_call]],
|
||||
FunctionCallContent,
|
||||
id="required_none_invoke",
|
||||
),
|
||||
pytest.param(FunctionChoiceBehavior.NoneInvoke(), [[mock_message_text_content]], TextContent, id="none"),
|
||||
],
|
||||
)
|
||||
async def test_complete_chat_contents_streaming_function_call_behavior_tool_call(
|
||||
kernel: Kernel,
|
||||
mock_settings: AnthropicChatPromptExecutionSettings,
|
||||
function_choice_behavior: FunctionChoiceBehavior,
|
||||
model_responses,
|
||||
expected_result,
|
||||
):
|
||||
mock_settings.function_choice_behavior = function_choice_behavior
|
||||
|
||||
# Mock sequence of model responses
|
||||
generator_mocks = []
|
||||
for mock_message in model_responses:
|
||||
generator_mock = MagicMock()
|
||||
generator_mock.__aiter__.return_value = [mock_message]
|
||||
generator_mocks.append(generator_mock)
|
||||
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = AnthropicChatCompletion(ai_model_id="test_model_id", service_id="test", api_key="")
|
||||
|
||||
with patch.object(chat_completion_base, "_inner_get_streaming_chat_message_contents", side_effect=generator_mocks):
|
||||
messages = []
|
||||
async for chunk in chat_completion_base.get_streaming_chat_message_contents(
|
||||
chat_history=ChatHistory(system_message="Test"), settings=mock_settings, kernel=kernel, arguments=arguments
|
||||
):
|
||||
messages.append(chunk)
|
||||
|
||||
response = messages[-1]
|
||||
assert all(isinstance(content, expected_result) for content in response[0].items)
|
||||
|
||||
|
||||
async def test_anthropic_sdk_exception(kernel: Kernel, mock_settings: AnthropicChatPromptExecutionSettings):
|
||||
client = MagicMock(spec=AsyncAnthropic)
|
||||
messages_mock = MagicMock()
|
||||
messages_mock.create.side_effect = Exception("Test Exception")
|
||||
client.messages = messages_mock
|
||||
|
||||
chat_history = MagicMock()
|
||||
arguments = KernelArguments()
|
||||
|
||||
chat_completion_base = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=client
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await chat_completion_base.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=mock_settings, kernel=kernel, arguments=arguments
|
||||
)
|
||||
|
||||
|
||||
async def test_anthropic_sdk_exception_streaming(kernel: Kernel, mock_settings: AnthropicChatPromptExecutionSettings):
|
||||
client = MagicMock(spec=AsyncAnthropic)
|
||||
messages_mock = MagicMock()
|
||||
messages_mock.stream.side_effect = Exception("Test Exception")
|
||||
client.messages = messages_mock
|
||||
|
||||
chat_history = MagicMock()
|
||||
arguments = KernelArguments()
|
||||
|
||||
chat_completion_base = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=client
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceResponseException):
|
||||
async for content in chat_completion_base.get_streaming_chat_message_contents(
|
||||
chat_history, mock_settings, kernel=kernel, arguments=arguments
|
||||
):
|
||||
assert content is not None
|
||||
|
||||
|
||||
def test_anthropic_chat_completion_init(anthropic_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
anthropic_chat_completion = AnthropicChatCompletion()
|
||||
|
||||
assert anthropic_chat_completion.ai_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"]
|
||||
assert isinstance(anthropic_chat_completion, ChatCompletionClientBase)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True)
|
||||
def test_anthropic_chat_completion_init_with_empty_api_key(anthropic_unit_test_env) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AnthropicChatCompletion(
|
||||
ai_model_id=ai_model_id,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["ANTHROPIC_CHAT_MODEL_ID"]], indirect=True)
|
||||
def test_anthropic_chat_completion_init_with_empty_model_id(anthropic_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AnthropicChatCompletion(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(anthropic_unit_test_env):
|
||||
anthropic_chat_completion = AnthropicChatCompletion()
|
||||
prompt_execution_settings = anthropic_chat_completion.get_prompt_execution_settings_class()
|
||||
assert prompt_execution_settings == AnthropicChatPromptExecutionSettings
|
||||
|
||||
|
||||
async def test_with_different_execution_settings(kernel: Kernel, mock_anthropic_client_completion: MagicMock):
|
||||
chat_history = MagicMock()
|
||||
settings = OpenAIChatPromptExecutionSettings(temperature=0.2)
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=mock_anthropic_client_completion
|
||||
)
|
||||
|
||||
await chat_completion_base.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=settings, kernel=kernel, arguments=arguments
|
||||
)
|
||||
|
||||
assert mock_anthropic_client_completion.messages.create.call_args.kwargs["temperature"] == 0.2
|
||||
|
||||
|
||||
async def test_with_different_execution_settings_stream(
|
||||
kernel: Kernel, mock_anthropic_client_completion_stream: MagicMock
|
||||
):
|
||||
chat_history = MagicMock()
|
||||
settings = OpenAIChatPromptExecutionSettings(temperature=0.2, seed=2)
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id",
|
||||
service_id="test",
|
||||
api_key="",
|
||||
async_client=mock_anthropic_client_completion_stream,
|
||||
)
|
||||
|
||||
async for chunk in chat_completion_base.get_streaming_chat_message_contents(
|
||||
chat_history, settings, kernel=kernel, arguments=arguments
|
||||
):
|
||||
assert chunk is not None
|
||||
assert mock_anthropic_client_completion_stream.messages.stream.call_args.kwargs["temperature"] == 0.2
|
||||
|
||||
|
||||
async def test_prepare_chat_history_for_request_with_system_message(mock_anthropic_client_completion_stream: MagicMock):
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_system_message("System message")
|
||||
chat_history.add_user_message("User message")
|
||||
chat_history.add_assistant_message("Assistant message")
|
||||
chat_history.add_system_message("Another system message")
|
||||
|
||||
chat_completion_base = 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_base._prepare_chat_history_for_request(
|
||||
chat_history, role_key="role", content_key="content"
|
||||
)
|
||||
|
||||
assert system_message_content == "System message"
|
||||
assert remaining_messages == [
|
||||
{"role": AuthorRole.USER, "content": "User message"},
|
||||
{"role": AuthorRole.ASSISTANT, "content": [{"type": "text", "text": "Assistant message"}]},
|
||||
]
|
||||
assert not any(msg["role"] == AuthorRole.SYSTEM for msg in remaining_messages)
|
||||
|
||||
|
||||
async def test_prepare_chat_history_for_request_with_tool_message(
|
||||
mock_anthropic_client_completion_stream: MagicMock,
|
||||
mock_tool_calls_message: ChatMessageContent,
|
||||
mock_tool_call_result_message: ChatMessageContent,
|
||||
):
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("What is 3+3?")
|
||||
chat_history.add_message(mock_tool_calls_message)
|
||||
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,
|
||||
)
|
||||
|
||||
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_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_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_tool_call_result_message: ChatMessageContent,
|
||||
):
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("What is 3+3?")
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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(
|
||||
mock_streaming_tool_calls_message: MagicMock,
|
||||
mock_streaming_chat_message_content: StreamingChatMessageContent,
|
||||
):
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("What is 3+3?")
|
||||
chat_history.add_message(mock_streaming_chat_message_content)
|
||||
|
||||
settings = AnthropicChatPromptExecutionSettings(
|
||||
temperature=0.2,
|
||||
max_tokens=100,
|
||||
top_p=1.0,
|
||||
frequency_penalty=0.0,
|
||||
presence_penalty=0.0,
|
||||
chat_history=chat_history,
|
||||
)
|
||||
|
||||
client = MagicMock(spec=AsyncAnthropic)
|
||||
messages_mock = MagicMock()
|
||||
messages_mock.stream.return_value = mock_streaming_tool_calls_message
|
||||
client.messages = messages_mock
|
||||
|
||||
chat_completion = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id",
|
||||
service_id="test",
|
||||
api_key="",
|
||||
async_client=client,
|
||||
)
|
||||
|
||||
response = chat_completion._send_chat_stream_request(settings)
|
||||
async for message in response:
|
||||
assert message is not None
|
||||
|
||||
|
||||
def test_client_base_url(mock_anthropic_client_completion: MagicMock):
|
||||
chat_completion_base = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=mock_anthropic_client_completion
|
||||
)
|
||||
|
||||
assert chat_completion_base.service_url() is not None
|
||||
|
||||
|
||||
def test_chat_completion_reset_settings(
|
||||
mock_anthropic_client_completion: MagicMock,
|
||||
):
|
||||
chat_completion = AnthropicChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=mock_anthropic_client_completion
|
||||
)
|
||||
|
||||
settings = AnthropicChatPromptExecutionSettings(tools=[{"name": "test"}], tool_choice={"type": "any"})
|
||||
chat_completion._reset_function_choice_settings(settings)
|
||||
|
||||
assert settings.tools is None
|
||||
assert settings.tool_choice is None
|
||||
@@ -0,0 +1,129 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.anthropic.prompt_execution_settings.anthropic_prompt_execution_settings import (
|
||||
AnthropicChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.exceptions import ServiceInvalidExecutionSettingsError
|
||||
|
||||
|
||||
def test_default_anthropic_chat_prompt_execution_settings():
|
||||
settings = AnthropicChatPromptExecutionSettings()
|
||||
assert settings.temperature is None
|
||||
assert settings.top_p is None
|
||||
assert settings.max_tokens == 1024
|
||||
assert settings.messages is None
|
||||
|
||||
|
||||
def test_custom_anthropic_chat_prompt_execution_settings():
|
||||
settings = AnthropicChatPromptExecutionSettings(
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
max_tokens=128,
|
||||
messages=[{"role": "system", "content": "Hello"}],
|
||||
)
|
||||
assert settings.temperature == 0.5
|
||||
assert settings.top_p == 0.5
|
||||
assert settings.max_tokens == 128
|
||||
assert settings.messages == [{"role": "system", "content": "Hello"}]
|
||||
|
||||
|
||||
def test_anthropic_chat_prompt_execution_settings_from_default_completion_config():
|
||||
settings = PromptExecutionSettings(service_id="test_service")
|
||||
chat_settings = AnthropicChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.temperature is None
|
||||
assert chat_settings.top_p is None
|
||||
assert chat_settings.max_tokens == 1024
|
||||
|
||||
|
||||
def test_anthropic_chat_prompt_execution_settings_from_openai_prompt_execution_settings():
|
||||
chat_settings = AnthropicChatPromptExecutionSettings(service_id="test_service", temperature=1.0)
|
||||
new_settings = AnthropicChatPromptExecutionSettings(service_id="test_2", temperature=0.0)
|
||||
chat_settings.update_from_prompt_execution_settings(new_settings)
|
||||
assert chat_settings.service_id == "test_2"
|
||||
assert chat_settings.temperature == 0.0
|
||||
|
||||
|
||||
def test_anthropic_chat_prompt_execution_settings_from_custom_completion_config():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
chat_settings = AnthropicChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_from_custom_completion_config_with_none():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
chat_settings = AnthropicChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_from_custom_completion_config_with_functions():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"tools": [{}],
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
chat_settings = AnthropicChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
|
||||
|
||||
def test_create_options():
|
||||
settings = AnthropicChatPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"tools": [{}],
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
options = settings.prepare_settings_dict()
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.5
|
||||
assert options["max_tokens"] == 128
|
||||
|
||||
|
||||
def test_tool_choice_none():
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError, match="Tool choice 'none' is not supported by Anthropic."):
|
||||
AnthropicChatPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"tool_choice": {"type": "none"},
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
function_choice_behavior=FunctionChoiceBehavior.NoneInvoke(),
|
||||
)
|
||||
@@ -0,0 +1,278 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import datetime
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from azure.ai.inference.aio import ChatCompletionsClient, EmbeddingsClient
|
||||
from azure.ai.inference.models import (
|
||||
ChatChoice,
|
||||
ChatCompletions,
|
||||
ChatCompletionsToolCall,
|
||||
ChatResponseMessage,
|
||||
CompletionsUsage,
|
||||
FunctionCall,
|
||||
StreamingChatChoiceUpdate,
|
||||
StreamingChatCompletionsUpdate,
|
||||
StreamingChatResponseToolCallUpdate,
|
||||
)
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference import (
|
||||
AzureAIInferenceChatCompletion,
|
||||
AzureAIInferenceTextEmbedding,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def model_id() -> str:
|
||||
return "test_model_id"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def service_id() -> str:
|
||||
return "test_service_id"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def azure_ai_inference_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Azure AI Inference Unit Tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"AZURE_AI_INFERENCE_API_KEY": "test-api-key",
|
||||
"AZURE_AI_INFERENCE_ENDPOINT": "https://test-endpoint.com",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def model_diagnostics_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Azure AI Inference Unit Tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS": "true",
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE": "true",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def disabled_model_diagnostics_test_env(monkeypatch):
|
||||
"""Fixture to disable diagnostics for tests that use mocking.
|
||||
|
||||
This is needed because AIInferenceInstrumentor's instrument/uninstrument
|
||||
cycle interferes with class-level mocking of ChatCompletionsClient.complete.
|
||||
"""
|
||||
monkeypatch.setenv("SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS", "false")
|
||||
monkeypatch.setenv("SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE", "false")
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def azure_ai_inference_client(azure_ai_inference_unit_test_env, request) -> ChatCompletionsClient | EmbeddingsClient:
|
||||
"""Fixture to create Azure AI Inference client for unit tests."""
|
||||
endpoint = azure_ai_inference_unit_test_env["AZURE_AI_INFERENCE_ENDPOINT"]
|
||||
api_key = azure_ai_inference_unit_test_env["AZURE_AI_INFERENCE_API_KEY"]
|
||||
credential = AzureKeyCredential(api_key)
|
||||
|
||||
if request.param == AzureAIInferenceChatCompletion.__name__:
|
||||
return ChatCompletionsClient(endpoint=endpoint, credential=credential)
|
||||
if request.param == AzureAIInferenceTextEmbedding.__name__:
|
||||
return EmbeddingsClient(endpoint=endpoint, credential=credential)
|
||||
|
||||
raise ValueError(f"Service {request.param} not supported.")
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def azure_ai_inference_service(azure_ai_inference_unit_test_env, model_id, request):
|
||||
"""Fixture to create Azure AI Inference service for unit tests.
|
||||
|
||||
This is required because the Azure AI Inference services require a client to be created,
|
||||
and the client will be talking to the endpoint at creation time.
|
||||
"""
|
||||
|
||||
endpoint = azure_ai_inference_unit_test_env["AZURE_AI_INFERENCE_ENDPOINT"]
|
||||
api_key = azure_ai_inference_unit_test_env["AZURE_AI_INFERENCE_API_KEY"]
|
||||
|
||||
if request.param == AzureAIInferenceChatCompletion.__name__:
|
||||
return AzureAIInferenceChatCompletion(model_id, api_key=api_key, endpoint=endpoint)
|
||||
if request.param == AzureAIInferenceTextEmbedding.__name__:
|
||||
return AzureAIInferenceTextEmbedding(model_id, api_key=api_key, endpoint=endpoint)
|
||||
|
||||
raise ValueError(f"Service {request.param} not supported.")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_azure_ai_inference_chat_completion_response(model_id) -> ChatCompletions:
|
||||
return ChatCompletions(
|
||||
id="test_id",
|
||||
created=datetime.datetime.now(),
|
||||
model=model_id,
|
||||
usage=CompletionsUsage(
|
||||
completion_tokens=0,
|
||||
prompt_tokens=0,
|
||||
total_tokens=0,
|
||||
),
|
||||
choices=[
|
||||
ChatChoice(
|
||||
index=0,
|
||||
finish_reason="stop",
|
||||
message=ChatResponseMessage(
|
||||
role="assistant",
|
||||
content="Hello",
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_azure_ai_inference_chat_completion_response_with_tool_call(model_id) -> ChatCompletions:
|
||||
return ChatCompletions(
|
||||
id="test_id",
|
||||
created=datetime.datetime.now(),
|
||||
model=model_id,
|
||||
usage=CompletionsUsage(
|
||||
completion_tokens=0,
|
||||
prompt_tokens=0,
|
||||
total_tokens=0,
|
||||
),
|
||||
choices=[
|
||||
ChatChoice(
|
||||
index=0,
|
||||
finish_reason="tool_calls",
|
||||
message=ChatResponseMessage(
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
ChatCompletionsToolCall(
|
||||
id="test_id",
|
||||
function=FunctionCall(
|
||||
name="getLightStatus",
|
||||
arguments='{"arg1": "test_value"}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_azure_ai_inference_streaming_chat_completion_response(model_id) -> AsyncIterator:
|
||||
streaming_chat_response = MagicMock(spec=AsyncGenerator)
|
||||
streaming_chat_response.__aiter__.return_value = [
|
||||
StreamingChatCompletionsUpdate(
|
||||
id="test_id",
|
||||
created=datetime.datetime.now(),
|
||||
model=model_id,
|
||||
usage=CompletionsUsage(
|
||||
completion_tokens=0,
|
||||
prompt_tokens=0,
|
||||
total_tokens=0,
|
||||
),
|
||||
choices=[
|
||||
# Empty choice
|
||||
],
|
||||
),
|
||||
StreamingChatCompletionsUpdate(
|
||||
id="test_id",
|
||||
created=datetime.datetime.now(),
|
||||
model=model_id,
|
||||
usage=CompletionsUsage(
|
||||
completion_tokens=0,
|
||||
prompt_tokens=0,
|
||||
total_tokens=0,
|
||||
),
|
||||
choices=[
|
||||
StreamingChatChoiceUpdate(
|
||||
index=0,
|
||||
finish_reason="stop",
|
||||
delta=ChatResponseMessage(
|
||||
role="assistant",
|
||||
content="Hello",
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
return streaming_chat_response
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_azure_ai_inference_streaming_chat_completion_response_with_tool_call(model_id) -> AsyncIterator:
|
||||
streaming_chat_response = MagicMock(spec=AsyncGenerator)
|
||||
streaming_chat_response.__aiter__.return_value = [
|
||||
StreamingChatCompletionsUpdate(
|
||||
id="test_id",
|
||||
created=datetime.datetime.now(),
|
||||
model=model_id,
|
||||
usage=CompletionsUsage(
|
||||
completion_tokens=0,
|
||||
prompt_tokens=0,
|
||||
total_tokens=0,
|
||||
),
|
||||
choices=[
|
||||
# Empty choice
|
||||
],
|
||||
),
|
||||
StreamingChatCompletionsUpdate(
|
||||
id="test_id",
|
||||
created=datetime.datetime.now(),
|
||||
model=model_id,
|
||||
usage=CompletionsUsage(
|
||||
completion_tokens=0,
|
||||
prompt_tokens=0,
|
||||
total_tokens=0,
|
||||
),
|
||||
choices=[
|
||||
StreamingChatChoiceUpdate(
|
||||
index=0,
|
||||
finish_reason="tool_calls",
|
||||
delta=ChatResponseMessage(
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
StreamingChatResponseToolCallUpdate(
|
||||
id="test_id",
|
||||
function=FunctionCall(
|
||||
name="getLightStatus",
|
||||
arguments='{"arg1": "test_value"}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
return streaming_chat_response
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.inference.aio import ChatCompletionsClient
|
||||
from azure.ai.inference.models import JsonSchemaFormat, UserMessage
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference import (
|
||||
AzureAIInferenceChatCompletion,
|
||||
AzureAIInferenceChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference.azure_ai_inference_settings import AzureAIInferenceSettings
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.utils.finish_reason import FinishReason
|
||||
from semantic_kernel.exceptions.service_exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
)
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from semantic_kernel.utils.telemetry.user_agent import SEMANTIC_KERNEL_USER_AGENT
|
||||
|
||||
|
||||
# region init
|
||||
def test_azure_ai_inference_chat_completion_init(azure_ai_inference_unit_test_env, model_id) -> None:
|
||||
"""Test initialization of AzureAIInferenceChatCompletion"""
|
||||
azure_ai_inference = AzureAIInferenceChatCompletion(model_id, instruction_role="developer")
|
||||
|
||||
assert azure_ai_inference.ai_model_id == model_id
|
||||
assert azure_ai_inference.service_id == model_id
|
||||
assert isinstance(azure_ai_inference.client, ChatCompletionsClient)
|
||||
assert azure_ai_inference.instruction_role == "developer"
|
||||
|
||||
|
||||
@patch("azure.ai.inference.aio.ChatCompletionsClient.__init__", return_value=None)
|
||||
def test_azure_ai_inference_chat_completion_client_init(
|
||||
mock_client, azure_ai_inference_unit_test_env, model_id
|
||||
) -> None:
|
||||
"""Test initialization of the Azure AI Inference client"""
|
||||
endpoint = azure_ai_inference_unit_test_env["AZURE_AI_INFERENCE_ENDPOINT"]
|
||||
api_key = azure_ai_inference_unit_test_env["AZURE_AI_INFERENCE_API_KEY"]
|
||||
settings = AzureAIInferenceSettings(endpoint=endpoint, api_key=api_key)
|
||||
|
||||
_ = AzureAIInferenceChatCompletion(model_id)
|
||||
|
||||
assert mock_client.call_count == 1
|
||||
assert isinstance(mock_client.call_args.kwargs["endpoint"], str)
|
||||
assert mock_client.call_args.kwargs["endpoint"] == str(settings.endpoint)
|
||||
assert isinstance(mock_client.call_args.kwargs["credential"], AzureKeyCredential)
|
||||
assert mock_client.call_args.kwargs["credential"].key == settings.api_key.get_secret_value()
|
||||
assert mock_client.call_args.kwargs["user_agent"] == SEMANTIC_KERNEL_USER_AGENT
|
||||
|
||||
|
||||
def test_azure_ai_inference_chat_completion_init_with_service_id(
|
||||
azure_ai_inference_unit_test_env, model_id, service_id
|
||||
) -> None:
|
||||
"""Test initialization of AzureAIInferenceChatCompletion with service_id"""
|
||||
azure_ai_inference = AzureAIInferenceChatCompletion(model_id, service_id=service_id)
|
||||
|
||||
assert azure_ai_inference.ai_model_id == model_id
|
||||
assert azure_ai_inference.service_id == service_id
|
||||
assert isinstance(azure_ai_inference.client, ChatCompletionsClient)
|
||||
|
||||
|
||||
def test_azure_ai_inference_chat_completion_init_with_api_version(azure_ai_inference_unit_test_env, model_id) -> None:
|
||||
"""Test initialization of AzureAIInferenceChatCompletion with api_version"""
|
||||
azure_ai_inference = AzureAIInferenceChatCompletion(model_id, api_version="2024-02-15-test")
|
||||
|
||||
assert azure_ai_inference.ai_model_id == model_id
|
||||
assert isinstance(azure_ai_inference.client, ChatCompletionsClient)
|
||||
assert azure_ai_inference.client._config.api_version == "2024-02-15-test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_client",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
def test_azure_ai_inference_chat_completion_init_with_custom_client(azure_ai_inference_client, model_id) -> None:
|
||||
"""Test initialization of AzureAIInferenceChatCompletion with custom client"""
|
||||
client = azure_ai_inference_client
|
||||
azure_ai_inference = AzureAIInferenceChatCompletion(model_id, client=client)
|
||||
|
||||
assert azure_ai_inference.ai_model_id == model_id
|
||||
assert azure_ai_inference.service_id == model_id
|
||||
assert azure_ai_inference.client == client
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_AI_INFERENCE_ENDPOINT"]], indirect=True)
|
||||
def test_azure_ai_inference_chat_completion_init_with_empty_endpoint(
|
||||
azure_ai_inference_unit_test_env, model_id
|
||||
) -> None:
|
||||
"""Test initialization of AzureAIInferenceChatCompletion with empty endpoint"""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureAIInferenceChatCompletion(model_id, env_file_path="fake_path")
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(azure_ai_inference_unit_test_env, model_id) -> None:
|
||||
azure_ai_inference = AzureAIInferenceChatCompletion(model_id)
|
||||
assert azure_ai_inference.get_prompt_execution_settings_class() == AzureAIInferenceChatPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion init
|
||||
|
||||
|
||||
# region chat completion
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_chat_completion(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion"""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings()
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_chat_completion_response
|
||||
|
||||
responses = await azure_ai_inference_service.get_chat_message_contents(chat_history=chat_history, settings=settings)
|
||||
|
||||
mock_complete.assert_awaited_once_with(
|
||||
messages=[UserMessage(content=user_message_content)],
|
||||
model=model_id,
|
||||
model_extras=None,
|
||||
**settings.prepare_settings_dict(),
|
||||
)
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == "Hello"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_chat_completion_with_standard_parameters(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion with standard OpenAI parameters"""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
frequency_penalty=0.5,
|
||||
max_tokens=100,
|
||||
presence_penalty=0.5,
|
||||
seed=123,
|
||||
stop="stop",
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_chat_completion_response
|
||||
|
||||
responses = await azure_ai_inference_service.get_chat_message_contents(chat_history=chat_history, settings=settings)
|
||||
|
||||
mock_complete.assert_awaited_once_with(
|
||||
messages=[UserMessage(content=user_message_content)],
|
||||
model=model_id,
|
||||
model_extras=None,
|
||||
frequency_penalty=settings.frequency_penalty,
|
||||
max_tokens=settings.max_tokens,
|
||||
presence_penalty=settings.presence_penalty,
|
||||
seed=settings.seed,
|
||||
stop=settings.stop,
|
||||
temperature=settings.temperature,
|
||||
top_p=settings.top_p,
|
||||
)
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == "Hello"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_chat_completion_with_extra_parameters(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion with extra parameters"""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
extra_parameters = {"test_key": "test_value"}
|
||||
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(extra_parameters=extra_parameters)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_chat_completion_response
|
||||
|
||||
responses = await azure_ai_inference_service.get_chat_message_contents(chat_history=chat_history, settings=settings)
|
||||
|
||||
mock_complete.assert_awaited_once_with(
|
||||
messages=[UserMessage(content=user_message_content)],
|
||||
model=model_id,
|
||||
model_extras=settings.extra_parameters,
|
||||
)
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == "Hello"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_azure_ai_inference_chat_completion_with_function_choice_behavior_fail_verification(
|
||||
azure_ai_inference_service,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion with function choice behavior expect verification failure"""
|
||||
|
||||
# Missing kernel
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
await azure_ai_inference_service.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
arguments=KernelArguments(),
|
||||
)
|
||||
|
||||
# More than 1 responses
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
extra_parameters={"n": 2},
|
||||
)
|
||||
await azure_ai_inference_service.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
kernel=kernel,
|
||||
arguments=KernelArguments(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_chat_completion_with_function_choice_behavior(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
kernel: Kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response_with_tool_call,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
decorated_native_function,
|
||||
disabled_model_diagnostics_test_env,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion with function choice behavior"""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
# First call returns tool call, second call returns final response
|
||||
mock_complete.side_effect = [
|
||||
mock_azure_ai_inference_chat_completion_response_with_tool_call,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
]
|
||||
|
||||
kernel.add_function(plugin_name="TestPlugin", function=decorated_native_function)
|
||||
|
||||
responses = await azure_ai_inference_service.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
kernel=kernel,
|
||||
arguments=KernelArguments(),
|
||||
)
|
||||
|
||||
# Completion should be called twice:
|
||||
# One for the tool call and one for the last completion
|
||||
# after the maximum_auto_invoke_attempts is reached
|
||||
assert mock_complete.call_count == 2
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == "Hello"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_chat_completion_with_function_choice_behavior_no_tool_call(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion with function choice behavior but no tool call"""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_chat_completion_response
|
||||
|
||||
responses = await azure_ai_inference_service.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
kernel=kernel,
|
||||
arguments=KernelArguments(),
|
||||
)
|
||||
|
||||
mock_complete.assert_awaited_once_with(
|
||||
messages=[UserMessage(content=user_message_content)],
|
||||
model=model_id,
|
||||
model_extras=None,
|
||||
**settings.prepare_settings_dict(),
|
||||
)
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == "Hello"
|
||||
|
||||
|
||||
class MockResponseModel(BaseModel):
|
||||
a: int = Field(..., description="The a field")
|
||||
b: str = Field(..., description="The b field")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_response_format_json_schema(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
):
|
||||
chat_history.add_user_message("Return an object with fields a and b.")
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
response_format=MockResponseModel,
|
||||
structured_json_response=True,
|
||||
)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_chat_completion_response
|
||||
|
||||
_ = await azure_ai_inference_service.get_chat_message_contents(chat_history=chat_history, settings=settings)
|
||||
|
||||
assert mock_complete.call_count == 1
|
||||
kwargs = mock_complete.call_args.kwargs
|
||||
|
||||
assert "response_format" in kwargs
|
||||
response_format = kwargs["response_format"]
|
||||
assert isinstance(response_format, JsonSchemaFormat)
|
||||
assert response_format.name == "MockResponseModel"
|
||||
assert response_format.strict is True
|
||||
|
||||
schema = response_format.schema
|
||||
assert schema["title"] == "MockResponseModel"
|
||||
assert "properties" in schema
|
||||
assert "a" in schema["properties"]
|
||||
assert schema["properties"]["a"]["type"] == "integer"
|
||||
assert "b" in schema["properties"]
|
||||
assert schema["properties"]["b"]["type"] == "string"
|
||||
|
||||
assert kwargs["messages"][0].content == "Return an object with fields a and b."
|
||||
assert kwargs["model"] == model_id
|
||||
|
||||
|
||||
# endregion chat completion
|
||||
|
||||
|
||||
# region streaming chat completion
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_streaming_chat_completion(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test streaming completion of AzureAIInferenceChatCompletion"""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings()
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response
|
||||
|
||||
async for messages in azure_ai_inference_service.get_streaming_chat_message_contents(chat_history, settings):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
assert messages[0].content == "Hello"
|
||||
|
||||
mock_complete.assert_awaited_once_with(
|
||||
stream=True,
|
||||
messages=[UserMessage(content=user_message_content)],
|
||||
model=model_id,
|
||||
model_extras=None,
|
||||
**settings.prepare_settings_dict(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_chat_streaming_completion_with_standard_parameters(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test streaming completion of AzureAIInferenceChatCompletion with standard OpenAI parameters"""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
frequency_penalty=0.5,
|
||||
max_tokens=100,
|
||||
presence_penalty=0.5,
|
||||
seed=123,
|
||||
stop="stop",
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response
|
||||
|
||||
async for messages in azure_ai_inference_service.get_streaming_chat_message_contents(chat_history, settings):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
assert messages[0].content == "Hello"
|
||||
|
||||
mock_complete.assert_awaited_once_with(
|
||||
stream=True,
|
||||
messages=[UserMessage(content=user_message_content)],
|
||||
model=model_id,
|
||||
model_extras=None,
|
||||
frequency_penalty=settings.frequency_penalty,
|
||||
max_tokens=settings.max_tokens,
|
||||
presence_penalty=settings.presence_penalty,
|
||||
seed=settings.seed,
|
||||
stop=settings.stop,
|
||||
temperature=settings.temperature,
|
||||
top_p=settings.top_p,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_streaming_chat_completion_with_extra_parameters(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test streaming completion of AzureAIInferenceChatCompletion with extra parameters"""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
extra_parameters = {"test_key": "test_value"}
|
||||
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(extra_parameters=extra_parameters)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response
|
||||
|
||||
async for messages in azure_ai_inference_service.get_streaming_chat_message_contents(chat_history, settings):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
assert messages[0].content == "Hello"
|
||||
|
||||
mock_complete.assert_awaited_once_with(
|
||||
stream=True,
|
||||
messages=[UserMessage(content=user_message_content)],
|
||||
model=model_id,
|
||||
model_extras=settings.extra_parameters,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_azure_ai_inference_streaming_chat_completion_with_function_choice_behavior_fail_verification(
|
||||
azure_ai_inference_service,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion with function choice behavior expect verification failure"""
|
||||
|
||||
# Missing kernel
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
async for _ in azure_ai_inference_service.get_streaming_chat_message_contents(
|
||||
chat_history,
|
||||
settings,
|
||||
arguments=KernelArguments(),
|
||||
):
|
||||
pass
|
||||
|
||||
# More than 1 responses
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
extra_parameters={"n": 2},
|
||||
)
|
||||
async for _ in azure_ai_inference_service.get_streaming_chat_message_contents(
|
||||
chat_history,
|
||||
settings,
|
||||
kernel=kernel,
|
||||
arguments=KernelArguments(),
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_streaming_chat_completion_with_function_choice_behavior(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
kernel: Kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response_with_tool_call,
|
||||
decorated_native_function,
|
||||
) -> None:
|
||||
"""Test streaming completion of AzureAIInferenceChatCompletion with function choice behavior."""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response_with_tool_call
|
||||
|
||||
kernel.add_function(plugin_name="TestPlugin", function=decorated_native_function)
|
||||
|
||||
all_messages = []
|
||||
async for messages in azure_ai_inference_service.get_streaming_chat_message_contents(
|
||||
chat_history,
|
||||
settings,
|
||||
kernel=kernel,
|
||||
arguments=KernelArguments(),
|
||||
):
|
||||
all_messages.extend(messages)
|
||||
|
||||
# Assert the number of total messages
|
||||
assert len(all_messages) == 2, f"Expected 2 messages, got {len(all_messages)}"
|
||||
|
||||
# Validate the first message
|
||||
assert all_messages[0].role == "assistant", f"Unexpected role for first message: {all_messages[0].role}"
|
||||
assert all_messages[0].content == "", f"Unexpected content for first message: {all_messages[0].content}"
|
||||
assert all_messages[0].finish_reason == FinishReason.TOOL_CALLS, (
|
||||
f"Unexpected finish reason for first message: {all_messages[0].finish_reason}"
|
||||
)
|
||||
|
||||
# Validate the second message
|
||||
assert all_messages[1].role == "tool", f"Unexpected role for second message: {all_messages[1].role}"
|
||||
assert all_messages[1].content == "", f"Unexpected content for second message: {all_messages[1].content}"
|
||||
assert all_messages[1].finish_reason is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_streaming_chat_completion_with_function_choice_behavior_no_tool_call(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test streaming completion of AzureAIInferenceChatCompletion with function choice behavior but no tool call"""
|
||||
user_message_content: str = "Hello"
|
||||
chat_history.add_user_message(user_message_content)
|
||||
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response
|
||||
|
||||
async for messages in azure_ai_inference_service.get_streaming_chat_message_contents(
|
||||
chat_history,
|
||||
settings,
|
||||
kernel=kernel,
|
||||
arguments=KernelArguments(),
|
||||
):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
assert messages[0].content == "Hello"
|
||||
|
||||
mock_complete.assert_awaited_once_with(
|
||||
stream=True,
|
||||
messages=[UserMessage(content=user_message_content)],
|
||||
model=model_id,
|
||||
model_extras=None,
|
||||
**settings.prepare_settings_dict(),
|
||||
)
|
||||
|
||||
|
||||
class MockStreamingResponseModel(BaseModel):
|
||||
foo: float = Field(..., description="Foo value")
|
||||
bar: bool = Field(..., description="Bar value")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_streaming_response_format_json_schema(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response,
|
||||
):
|
||||
chat_history.add_user_message("Stream a response with foo and bar.")
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
response_format=MockStreamingResponseModel,
|
||||
structured_json_response=True,
|
||||
)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response
|
||||
|
||||
messages = []
|
||||
async for chunk in azure_ai_inference_service.get_streaming_chat_message_contents(chat_history, settings):
|
||||
messages.extend(chunk)
|
||||
|
||||
assert mock_complete.call_count == 1
|
||||
kwargs = mock_complete.call_args.kwargs
|
||||
assert "response_format" in kwargs
|
||||
response_format = kwargs["response_format"]
|
||||
assert isinstance(response_format, JsonSchemaFormat)
|
||||
assert response_format.name == "MockStreamingResponseModel"
|
||||
assert response_format.strict is True
|
||||
schema = response_format.schema
|
||||
assert schema["title"] == "MockStreamingResponseModel"
|
||||
assert "foo" in schema["properties"]
|
||||
assert schema["properties"]["foo"]["type"] == "number"
|
||||
assert "bar" in schema["properties"]
|
||||
assert schema["properties"]["bar"]["type"] == "boolean"
|
||||
assert kwargs["stream"] is True
|
||||
assert kwargs["model"] == model_id
|
||||
|
||||
|
||||
# endregion streaming chat completion
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.inference.aio import EmbeddingsClient
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference import (
|
||||
AzureAIInferenceEmbeddingPromptExecutionSettings,
|
||||
AzureAIInferenceTextEmbedding,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference.azure_ai_inference_settings import AzureAIInferenceSettings
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
from semantic_kernel.utils.telemetry.user_agent import SEMANTIC_KERNEL_USER_AGENT
|
||||
|
||||
|
||||
def test_azure_ai_inference_text_embedding_init(azure_ai_inference_unit_test_env, model_id) -> None:
|
||||
"""Test initialization of AzureAIInferenceTextEmbedding"""
|
||||
azure_ai_inference = AzureAIInferenceTextEmbedding(model_id)
|
||||
|
||||
assert azure_ai_inference.ai_model_id == model_id
|
||||
assert azure_ai_inference.service_id == model_id
|
||||
assert isinstance(azure_ai_inference.client, EmbeddingsClient)
|
||||
|
||||
|
||||
@patch("azure.ai.inference.aio.EmbeddingsClient.__init__", return_value=None)
|
||||
def test_azure_ai_inference_text_embedding_client_init(mock_client, azure_ai_inference_unit_test_env, model_id) -> None:
|
||||
"""Test initialization of the Azure AI Inference client"""
|
||||
endpoint = azure_ai_inference_unit_test_env["AZURE_AI_INFERENCE_ENDPOINT"]
|
||||
api_key = azure_ai_inference_unit_test_env["AZURE_AI_INFERENCE_API_KEY"]
|
||||
settings = AzureAIInferenceSettings(endpoint=endpoint, api_key=api_key)
|
||||
|
||||
_ = AzureAIInferenceTextEmbedding(model_id)
|
||||
|
||||
assert mock_client.call_count == 1
|
||||
assert isinstance(mock_client.call_args.kwargs["endpoint"], str)
|
||||
assert mock_client.call_args.kwargs["endpoint"] == str(settings.endpoint)
|
||||
assert isinstance(mock_client.call_args.kwargs["credential"], AzureKeyCredential)
|
||||
assert mock_client.call_args.kwargs["credential"].key == settings.api_key.get_secret_value()
|
||||
assert mock_client.call_args.kwargs["user_agent"] == SEMANTIC_KERNEL_USER_AGENT
|
||||
|
||||
|
||||
def test_azure_ai_inference_text_embedding_init_with_service_id(
|
||||
azure_ai_inference_unit_test_env, model_id, service_id
|
||||
) -> None:
|
||||
"""Test initialization of AzureAIInferenceTextEmbedding"""
|
||||
azure_ai_inference = AzureAIInferenceTextEmbedding(model_id, service_id=service_id)
|
||||
|
||||
assert azure_ai_inference.ai_model_id == model_id
|
||||
assert azure_ai_inference.service_id == service_id
|
||||
assert isinstance(azure_ai_inference.client, EmbeddingsClient)
|
||||
|
||||
|
||||
def test_azure_ai_inference_text_embedding_init_with_api_version(azure_ai_inference_unit_test_env, model_id) -> None:
|
||||
"""Test initialization of AzureAIInferenceTextEmbedding with api_version"""
|
||||
azure_ai_inference = AzureAIInferenceTextEmbedding(model_id, api_version="2024-02-15-test")
|
||||
|
||||
assert azure_ai_inference.ai_model_id == model_id
|
||||
assert isinstance(azure_ai_inference.client, EmbeddingsClient)
|
||||
assert azure_ai_inference.client._config.api_version == "2024-02-15-test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_client",
|
||||
[AzureAIInferenceTextEmbedding.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
def test_azure_ai_inference_chat_completion_init_with_custom_client(azure_ai_inference_client, model_id) -> None:
|
||||
"""Test initialization of AzureAIInferenceTextEmbedding with custom client"""
|
||||
client = azure_ai_inference_client
|
||||
azure_ai_inference = AzureAIInferenceTextEmbedding(model_id, client=client)
|
||||
|
||||
assert azure_ai_inference.ai_model_id == model_id
|
||||
assert azure_ai_inference.service_id == model_id
|
||||
assert azure_ai_inference.client == client
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_AI_INFERENCE_ENDPOINT"]], indirect=True)
|
||||
def test_azure_ai_inference_text_embedding_init_with_empty_endpoint(azure_ai_inference_unit_test_env, model_id) -> None:
|
||||
"""Test initialization of AzureAIInferenceTextEmbedding with empty endpoint"""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureAIInferenceTextEmbedding(model_id, env_file_path="fake_path")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceTextEmbedding.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(EmbeddingsClient, "embed", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_text_embedding(
|
||||
mock_embed,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
) -> None:
|
||||
"""Test text embedding generation of AzureAIInferenceTextEmbedding without settings"""
|
||||
texts = ["hello", "world"]
|
||||
await azure_ai_inference_service.generate_embeddings(texts)
|
||||
|
||||
mock_embed.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=model_id,
|
||||
model_extras=None,
|
||||
dimensions=None,
|
||||
encoding_format=None,
|
||||
input_type=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceTextEmbedding.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(EmbeddingsClient, "embed", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_text_embedding_with_standard_settings(
|
||||
mock_embed,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
) -> None:
|
||||
"""Test text embedding generation of AzureAIInferenceTextEmbedding with standard settings"""
|
||||
texts = ["hello", "world"]
|
||||
settings = AzureAIInferenceEmbeddingPromptExecutionSettings(
|
||||
dimensions=1024, encoding_format="float", input_type="text"
|
||||
)
|
||||
await azure_ai_inference_service.generate_embeddings(texts, settings)
|
||||
|
||||
mock_embed.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=model_id,
|
||||
model_extras=None,
|
||||
dimensions=settings.dimensions,
|
||||
encoding_format=settings.encoding_format,
|
||||
input_type=settings.input_type,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceTextEmbedding.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(EmbeddingsClient, "embed", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_text_embedding_with_extra_parameters(
|
||||
mock_embed,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
) -> None:
|
||||
"""Test text embedding generation of AzureAIInferenceTextEmbedding with extra parameters"""
|
||||
texts = ["hello", "world"]
|
||||
extra_parameters = {"test_key": "test_value"}
|
||||
settings = AzureAIInferenceEmbeddingPromptExecutionSettings(extra_parameters=extra_parameters)
|
||||
await azure_ai_inference_service.generate_embeddings(texts, settings)
|
||||
|
||||
mock_embed.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=model_id,
|
||||
model_extras=extra_parameters,
|
||||
dimensions=settings.dimensions,
|
||||
encoding_format=settings.encoding_format,
|
||||
input_type=settings.input_type,
|
||||
)
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.inference.aio import ChatCompletionsClient
|
||||
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference.azure_ai_inference_prompt_execution_settings import (
|
||||
AzureAIInferenceChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference.services.azure_ai_inference_chat_completion import (
|
||||
AzureAIInferenceChatCompletion,
|
||||
)
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.uninstrument")
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.instrument")
|
||||
async def test_azure_ai_inference_chat_completion_instrumentation(
|
||||
mock_instrument,
|
||||
mock_uninstrument,
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
model_diagnostics_test_env,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion"""
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings()
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_chat_completion_response
|
||||
|
||||
await azure_ai_inference_service.get_chat_message_contents(chat_history=chat_history, settings=settings)
|
||||
|
||||
mock_instrument.assert_called_once_with(enable_content_recording=True)
|
||||
mock_uninstrument.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[
|
||||
AzureAIInferenceChatCompletion.__name__,
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[
|
||||
{
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS": "False",
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE": "False",
|
||||
},
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.uninstrument")
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.instrument")
|
||||
async def test_azure_ai_inference_chat_completion_not_instrumentation(
|
||||
mock_instrument,
|
||||
mock_uninstrument,
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
model_diagnostics_test_env,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion"""
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings()
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_chat_completion_response
|
||||
|
||||
await azure_ai_inference_service.get_chat_message_contents(chat_history=chat_history, settings=settings)
|
||||
|
||||
mock_instrument.assert_not_called()
|
||||
mock_uninstrument.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[
|
||||
AzureAIInferenceChatCompletion.__name__,
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[
|
||||
{
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS": "True",
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE": "False",
|
||||
},
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.uninstrument")
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.instrument")
|
||||
async def test_azure_ai_inference_chat_completion_instrumentation_without_sensitive(
|
||||
mock_instrument,
|
||||
mock_uninstrument,
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
model_diagnostics_test_env,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion"""
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings()
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_chat_completion_response
|
||||
|
||||
await azure_ai_inference_service.get_chat_message_contents(chat_history=chat_history, settings=settings)
|
||||
|
||||
mock_instrument.assert_called_once_with(enable_content_recording=False)
|
||||
mock_uninstrument.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.uninstrument")
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.instrument")
|
||||
async def test_azure_ai_inference_streaming_chat_completion_instrumentation(
|
||||
mock_instrument,
|
||||
mock_uninstrument,
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response,
|
||||
model_diagnostics_test_env,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion"""
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings()
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response
|
||||
|
||||
async for _ in azure_ai_inference_service.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history, settings=settings
|
||||
):
|
||||
pass
|
||||
|
||||
mock_instrument.assert_called_once_with(enable_content_recording=True)
|
||||
mock_uninstrument.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[
|
||||
AzureAIInferenceChatCompletion.__name__,
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[
|
||||
{
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS": "False",
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE": "False",
|
||||
},
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.uninstrument")
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.instrument")
|
||||
async def test_azure_ai_inference_streaming_chat_completion_not_instrumentation(
|
||||
mock_instrument,
|
||||
mock_uninstrument,
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response,
|
||||
model_diagnostics_test_env,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion"""
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings()
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response
|
||||
|
||||
async for _ in azure_ai_inference_service.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history, settings=settings
|
||||
):
|
||||
pass
|
||||
|
||||
mock_instrument.assert_not_called()
|
||||
mock_uninstrument.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[
|
||||
AzureAIInferenceChatCompletion.__name__,
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[
|
||||
{
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS": "True",
|
||||
"SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE": "False",
|
||||
},
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.uninstrument")
|
||||
@patch("azure.ai.inference.tracing.AIInferenceInstrumentor.instrument")
|
||||
async def test_azure_ai_inference_streaming_chat_completion_instrumentation_without_sensitive(
|
||||
mock_instrument,
|
||||
mock_uninstrument,
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response,
|
||||
model_diagnostics_test_env,
|
||||
) -> None:
|
||||
"""Test completion of AzureAIInferenceChatCompletion"""
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings()
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response
|
||||
|
||||
async for _ in azure_ai_inference_service.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history, settings=settings
|
||||
):
|
||||
pass
|
||||
|
||||
mock_instrument.assert_called_once_with(enable_content_recording=False)
|
||||
mock_uninstrument.assert_called_once()
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
import pytest
|
||||
from azure.ai.inference.models import (
|
||||
AssistantMessage,
|
||||
ImageContentItem,
|
||||
SystemMessage,
|
||||
TextContentItem,
|
||||
ToolMessage,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference.services.utils import MESSAGE_CONVERTERS
|
||||
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.image_content import ImageContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
|
||||
def test_message_convertors_contain_all_author_roles() -> None:
|
||||
"""Test that all AuthorRoles are present in the MESSAGE_CONVERTERS dict."""
|
||||
for role in AuthorRole:
|
||||
assert role in MESSAGE_CONVERTERS
|
||||
|
||||
|
||||
def test_format_system_message() -> None:
|
||||
"""Test that a system message is formatted correctly."""
|
||||
message = ChatMessageContent(role=AuthorRole.SYSTEM, content="test content")
|
||||
system_message = MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
assert isinstance(system_message, SystemMessage)
|
||||
assert system_message.content == message.content
|
||||
|
||||
|
||||
def test_format_user_message_with_no_image() -> None:
|
||||
"""Test that a user message with no image items is formatted correctly."""
|
||||
message = ChatMessageContent(role=AuthorRole.USER, content="test content")
|
||||
user_message = MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
assert isinstance(user_message, UserMessage)
|
||||
assert user_message.content == message.content
|
||||
|
||||
|
||||
def test_format_user_message_with_image() -> None:
|
||||
"""Test that a user message with image items is formatted correctly"""
|
||||
message = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[
|
||||
TextContent(text="test text"),
|
||||
ImageContent(uri="https://test.com/image.jpg"),
|
||||
],
|
||||
)
|
||||
user_message = MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
assert isinstance(user_message, UserMessage)
|
||||
assert len(user_message.content) == 2
|
||||
assert isinstance(user_message.content[0], TextContentItem)
|
||||
assert isinstance(user_message.content[1], ImageContentItem)
|
||||
|
||||
|
||||
def test_format_user_message_with_unsupported_items() -> None:
|
||||
"""Test that a user message with unsupported items is formatted correctly"""
|
||||
message = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[
|
||||
TextContent(text="test text"),
|
||||
ImageContent(), # ImageContent without uri or data_uri is unsupported
|
||||
FunctionCallContent(id="test function"), # FunctionCallContent unsupported
|
||||
],
|
||||
)
|
||||
user_message = MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
assert isinstance(user_message, UserMessage)
|
||||
assert len(user_message.content) == 1
|
||||
assert isinstance(user_message.content[0], TextContentItem)
|
||||
|
||||
|
||||
def test_format_assistant_message() -> None:
|
||||
"""Test that an assistant message is formatted correctly."""
|
||||
message = ChatMessageContent(role=AuthorRole.ASSISTANT, content="test content")
|
||||
assistant_message = MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
assert isinstance(assistant_message, AssistantMessage)
|
||||
assert assistant_message.content == message.content
|
||||
|
||||
|
||||
def test_format_assistant_message_with_tool_call() -> None:
|
||||
"""Test that an assistant message with a tool call is formatted correctly."""
|
||||
function_call_content = FunctionCallContent(id="test function")
|
||||
|
||||
message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[function_call_content],
|
||||
)
|
||||
assistant_message = MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
assert isinstance(assistant_message, AssistantMessage)
|
||||
assert assistant_message.content == message.content
|
||||
assert len(assistant_message.tool_calls) == 1
|
||||
assert assistant_message.tool_calls[0].id == function_call_content.id
|
||||
|
||||
|
||||
def test_format_assistant_message_with_unsupported_items() -> None:
|
||||
"""Test that an assistant message with unsupported items is formatted correctly."""
|
||||
text_content = TextContent(text="test text")
|
||||
|
||||
message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
text_content,
|
||||
ImageContent(), # ImageContent is unsupported
|
||||
],
|
||||
)
|
||||
assistant_message = MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
assert isinstance(assistant_message, AssistantMessage)
|
||||
assert assistant_message.content == message.content
|
||||
|
||||
|
||||
def test_format_tool_message() -> None:
|
||||
"""Test that a tool message is formatted correctly."""
|
||||
function_result_content = FunctionResultContent(id="test function", result="test result")
|
||||
|
||||
message = ChatMessageContent(
|
||||
role=AuthorRole.TOOL,
|
||||
items=[function_result_content],
|
||||
)
|
||||
tool_message = MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.content == function_result_content.result
|
||||
assert tool_message.tool_call_id == function_result_content.id
|
||||
|
||||
|
||||
def test_format_tool_message_item_not_found_as_the_first_item() -> None:
|
||||
"""Test that formatting a tool message where the function result item is not the first item."""
|
||||
function_result_content = FunctionResultContent(id="test function", result="test result")
|
||||
|
||||
message = ChatMessageContent(
|
||||
role=AuthorRole.TOOL,
|
||||
items=[
|
||||
TextContent(text="test text"),
|
||||
function_result_content,
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
|
||||
def test_format_tool_message_with_more_than_one_items() -> None:
|
||||
"""Test that a tool message with more than one item is formatted correctly."""
|
||||
function_result_content = FunctionResultContent(id="test function", result="test result")
|
||||
|
||||
message = ChatMessageContent(
|
||||
role=AuthorRole.TOOL,
|
||||
items=[
|
||||
function_result_content,
|
||||
TextContent(text="test text"),
|
||||
],
|
||||
)
|
||||
tool_message = MESSAGE_CONVERTERS[message.role](message)
|
||||
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.content == function_result_content.result
|
||||
assert tool_message.tool_call_id == function_result_content.id
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference import (
|
||||
AzureAIInferenceChatPromptExecutionSettings,
|
||||
AzureAIInferenceEmbeddingPromptExecutionSettings,
|
||||
AzureAIInferencePromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
|
||||
|
||||
def test_default_azure_ai_inference_prompt_execution_settings():
|
||||
settings = AzureAIInferencePromptExecutionSettings()
|
||||
|
||||
assert settings.frequency_penalty is None
|
||||
assert settings.max_tokens is None
|
||||
assert settings.presence_penalty is None
|
||||
assert settings.seed is None
|
||||
assert settings.stop is None
|
||||
assert settings.temperature is None
|
||||
assert settings.top_p is None
|
||||
assert settings.extra_parameters is None
|
||||
|
||||
|
||||
def test_custom_azure_ai_inference_prompt_execution_settings():
|
||||
settings = AzureAIInferencePromptExecutionSettings(
|
||||
frequency_penalty=0.5,
|
||||
max_tokens=128,
|
||||
presence_penalty=0.5,
|
||||
seed=1,
|
||||
stop="world",
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
extra_parameters={"key": "value"},
|
||||
)
|
||||
|
||||
assert settings.frequency_penalty == 0.5
|
||||
assert settings.max_tokens == 128
|
||||
assert settings.presence_penalty == 0.5
|
||||
assert settings.seed == 1
|
||||
assert settings.stop == "world"
|
||||
assert settings.temperature == 0.5
|
||||
assert settings.top_p == 0.5
|
||||
assert settings.extra_parameters == {"key": "value"}
|
||||
|
||||
|
||||
def test_azure_ai_inference_prompt_execution_settings_from_default_completion_config():
|
||||
settings = PromptExecutionSettings(service_id="test_service")
|
||||
chat_settings = AzureAIInferenceChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.frequency_penalty is None
|
||||
assert chat_settings.max_tokens is None
|
||||
assert chat_settings.presence_penalty is None
|
||||
assert chat_settings.seed is None
|
||||
assert chat_settings.stop is None
|
||||
assert chat_settings.temperature is None
|
||||
assert chat_settings.top_p is None
|
||||
assert chat_settings.extra_parameters is None
|
||||
|
||||
|
||||
def test_azure_ai_inference_prompt_execution_settings_from_openai_prompt_execution_settings():
|
||||
chat_settings = AzureAIInferenceChatPromptExecutionSettings(service_id="test_service", temperature=1.0)
|
||||
new_settings = AzureAIInferencePromptExecutionSettings(service_id="test_2", temperature=0.0)
|
||||
chat_settings.update_from_prompt_execution_settings(new_settings)
|
||||
|
||||
assert chat_settings.service_id == "test_2"
|
||||
assert chat_settings.temperature == 0.0
|
||||
|
||||
|
||||
def test_azure_ai_inference_prompt_execution_settings_from_custom_completion_config():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"frequency_penalty": 0.5,
|
||||
"max_tokens": 128,
|
||||
"presence_penalty": 0.5,
|
||||
"seed": 1,
|
||||
"stop": "world",
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"extra_parameters": {"key": "value"},
|
||||
},
|
||||
)
|
||||
chat_settings = AzureAIInferenceChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.frequency_penalty == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
assert chat_settings.presence_penalty == 0.5
|
||||
assert chat_settings.seed == 1
|
||||
assert chat_settings.stop == "world"
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.extra_parameters == {"key": "value"}
|
||||
|
||||
|
||||
def test_azure_ai_inference_chat_prompt_execution_settings_from_custom_completion_config_with_functions():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"tools": [{"function": {}}],
|
||||
},
|
||||
)
|
||||
chat_settings = AzureAIInferenceChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.tools == [{"function": {}}]
|
||||
|
||||
|
||||
def test_create_options():
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"frequency_penalty": 0.5,
|
||||
"max_tokens": 128,
|
||||
"presence_penalty": 0.5,
|
||||
"seed": 1,
|
||||
"stop": "world",
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"extra_parameters": {"key": "value"},
|
||||
},
|
||||
)
|
||||
options = settings.prepare_settings_dict()
|
||||
|
||||
assert options["frequency_penalty"] == 0.5
|
||||
assert options["max_tokens"] == 128
|
||||
assert options["presence_penalty"] == 0.5
|
||||
assert options["seed"] == 1
|
||||
assert options["stop"] == "world"
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.5
|
||||
assert options["extra_parameters"] == {"key": "value"}
|
||||
assert "tools" not in options
|
||||
assert "tool_config" not in options
|
||||
|
||||
|
||||
def test_default_azure_ai_inference_embedding_prompt_execution_settings():
|
||||
settings = AzureAIInferenceEmbeddingPromptExecutionSettings()
|
||||
|
||||
assert settings.dimensions is None
|
||||
assert settings.encoding_format is None
|
||||
assert settings.input_type is None
|
||||
assert settings.extra_parameters is None
|
||||
@@ -0,0 +1,320 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.bedrock.services.model_provider.bedrock_model_provider import BedrockModelProvider
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def model_id(request) -> str:
|
||||
if hasattr(request, "param"):
|
||||
return request.param
|
||||
return "test_model_id"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def service_id() -> str:
|
||||
return "test_service_id"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def chat_history() -> ChatHistory:
|
||||
chat_history = ChatHistory(system_message="You are a helpful assistant.")
|
||||
|
||||
chat_history.add_user_message("Hello!")
|
||||
chat_history.add_assistant_message("Hi! How can I help you today?")
|
||||
chat_history.add_system_message("Be polite and respectful.")
|
||||
chat_history.add_user_message("I need help with a task.")
|
||||
|
||||
return chat_history
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bedrock_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Amazon Bedrock AI connector unit tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"BEDROCK_TEXT_MODEL_ID": "env_test_text_model_id",
|
||||
"BEDROCK_CHAT_MODEL_ID": "env_test_chat_model_id",
|
||||
"BEDROCK_EMBEDDING_MODEL_ID": "env_test_embedding_model_id",
|
||||
"BEDROCK_MODEL_PROVIDER": "amazon",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
class MockBedrockClient(Mock):
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get_foundation_model(self, *args, **kwargs):
|
||||
return {
|
||||
"modelDetails": {
|
||||
"responseStreamingSupported": True,
|
||||
"inputModalities": ["TEXT"],
|
||||
"outputModalities": ["TEXT", "EMBEDDING"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MockBedrockRuntimeClient(Mock):
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def converse(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def converse_stream(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def invoke_model(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def invoke_model_with_response_stream(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
# region mock chat completion responses
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_bedrock_chat_completion_response():
|
||||
# https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-call.html#conversation-inference-call-response
|
||||
return {
|
||||
"output": {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"text": "Hi! How can I help you today?",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {
|
||||
"inputTokens": 125,
|
||||
"outputTokens": 60,
|
||||
"totalTokens": 185,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_bedrock_streaming_chat_completion_response():
|
||||
# https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-call.html#conversation-inference-call-response
|
||||
events = [
|
||||
{"messageStart": {"role": "assistant"}},
|
||||
{"contentBlockStart": {"contentBlockIndex": 0, "start": {}}},
|
||||
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Hi! "}}},
|
||||
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "How can "}}},
|
||||
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "I help you today?"}}},
|
||||
{"contentBlockStop": {"contentBlockIndex": 0}},
|
||||
{"messageStop": {"stopReason": "end_turn"}},
|
||||
{
|
||||
"metadata": {
|
||||
"metrics": {"latencyMs": 1000},
|
||||
"usage": {"inputTokens": 125, "outputTokens": 60, "totalTokens": 185},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
def event_stream(events):
|
||||
yield from events
|
||||
|
||||
return {"stream": event_stream(events)}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_bedrock_streaming_chat_completion_invalid_response():
|
||||
events = [
|
||||
{"unknown": {}},
|
||||
]
|
||||
|
||||
def event_stream(events):
|
||||
yield from events
|
||||
|
||||
return {"stream": event_stream(events)}
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region mock text completion responses
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def output_text():
|
||||
return "Hi! How can I help you today?"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def model_provider():
|
||||
return BedrockModelProvider.AMAZON
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_bedrock_text_completion_response(
|
||||
model_id: str,
|
||||
output_text: str,
|
||||
request,
|
||||
):
|
||||
# Check if model_provider fixture is requested by the test
|
||||
model_provider = None
|
||||
if "model_provider" in request.fixturenames:
|
||||
model_provider = request.getfixturevalue("model_provider")
|
||||
else:
|
||||
model_provider = BedrockModelProvider.to_model_provider(model_id)
|
||||
|
||||
match model_provider:
|
||||
case BedrockModelProvider.AMAZON:
|
||||
body = {
|
||||
"inputTextTokenCount": 10,
|
||||
"results": [
|
||||
{
|
||||
"tokenCount": 10,
|
||||
"outputText": output_text,
|
||||
"completionReason": "FINISHED ",
|
||||
}
|
||||
],
|
||||
}
|
||||
case BedrockModelProvider.ANTHROPIC:
|
||||
body = {
|
||||
"completion": output_text,
|
||||
"stop_reason": "stop_sequence",
|
||||
"stop": "",
|
||||
}
|
||||
case BedrockModelProvider.COHERE:
|
||||
body = {
|
||||
"generations": [
|
||||
{
|
||||
"text": output_text,
|
||||
}
|
||||
],
|
||||
}
|
||||
case BedrockModelProvider.AI21LABS:
|
||||
body = {
|
||||
"completions": [
|
||||
{
|
||||
"data": {
|
||||
"text": output_text,
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
case BedrockModelProvider.META:
|
||||
body = {
|
||||
"generation": output_text,
|
||||
"prompt_token_count": 10,
|
||||
"generation_token_count": 10,
|
||||
}
|
||||
case BedrockModelProvider.MISTRALAI:
|
||||
body = {"outputs": [{"text": output_text}]}
|
||||
|
||||
mock = Mock()
|
||||
mock.read.return_value = json.dumps(body)
|
||||
|
||||
return {"body": mock}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_bedrock_streaming_text_completion_response(
|
||||
model_id: str,
|
||||
output_text: str,
|
||||
request,
|
||||
):
|
||||
# Check if model_provider fixture is requested by the test
|
||||
model_provider = None
|
||||
if "model_provider" in request.fixturenames:
|
||||
model_provider = request.getfixturevalue("model_provider")
|
||||
else:
|
||||
model_provider = BedrockModelProvider.to_model_provider(model_id)
|
||||
|
||||
match model_provider:
|
||||
case BedrockModelProvider.AMAZON:
|
||||
chunks = [
|
||||
{
|
||||
"chunk": {
|
||||
"bytes": json.dumps({
|
||||
"inputTextTokenCount": 10,
|
||||
"totalOutputTextTokenCount": 10,
|
||||
"outputText": chunk,
|
||||
}).encode(),
|
||||
}
|
||||
}
|
||||
for chunk in [output_text[i : i + 3] for i in range(0, len(output_text), 3)]
|
||||
]
|
||||
|
||||
def event_stream(events):
|
||||
yield from events
|
||||
|
||||
return {"body": event_stream(chunks)}
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region mock text embedding responses
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_bedrock_text_embedding_response(
|
||||
model_id: str,
|
||||
request,
|
||||
):
|
||||
# Check if model_provider fixture is requested by the test
|
||||
model_provider = None
|
||||
if "model_provider" in request.fixturenames:
|
||||
model_provider = request.getfixturevalue("model_provider")
|
||||
else:
|
||||
model_provider = BedrockModelProvider.to_model_provider(model_id)
|
||||
|
||||
match model_provider:
|
||||
case BedrockModelProvider.AMAZON:
|
||||
body = {
|
||||
"embedding": [0.1, 0.2, 0.3],
|
||||
}
|
||||
case BedrockModelProvider.COHERE:
|
||||
body = {
|
||||
"embeddings": [[0.1, 0.2, 0.3]],
|
||||
}
|
||||
|
||||
mock = Mock()
|
||||
mock.read.return_value = json.dumps(body)
|
||||
|
||||
return {"body": mock}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_bedrock_text_embedding_invalid_response(model_id: str):
|
||||
model_provider = BedrockModelProvider.to_model_provider(model_id)
|
||||
|
||||
match model_provider:
|
||||
case BedrockModelProvider.AMAZON:
|
||||
body = {"embedding": 0.1}
|
||||
case BedrockModelProvider.COHERE:
|
||||
body = {"embeddings": 0.1}
|
||||
|
||||
mock = Mock()
|
||||
mock.read.return_value = json.dumps(body)
|
||||
|
||||
return {"body": mock}
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,371 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from functools import reduce
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.bedrock.bedrock_prompt_execution_settings import BedrockChatPromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.bedrock.services.bedrock_chat_completion import BedrockChatCompletion
|
||||
from semantic_kernel.connectors.ai.bedrock.services.model_provider.bedrock_model_provider import BedrockModelProvider
|
||||
from semantic_kernel.connectors.ai.completion_usage import CompletionUsage
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.contents.utils.finish_reason import FinishReason
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceInvalidResponseError
|
||||
from tests.unit.connectors.ai.bedrock.conftest import MockBedrockClient, MockBedrockRuntimeClient
|
||||
|
||||
# region init
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_chat_completion_init(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service"""
|
||||
bedrock_chat_completion = BedrockChatCompletion()
|
||||
|
||||
assert bedrock_chat_completion.ai_model_id == bedrock_unit_test_env["BEDROCK_CHAT_MODEL_ID"]
|
||||
assert bedrock_chat_completion.service_id == bedrock_unit_test_env["BEDROCK_CHAT_MODEL_ID"]
|
||||
|
||||
assert bedrock_chat_completion.bedrock_model_provider == BedrockModelProvider(
|
||||
bedrock_unit_test_env["BEDROCK_MODEL_PROVIDER"]
|
||||
)
|
||||
assert bedrock_chat_completion.bedrock_client is not None
|
||||
assert bedrock_chat_completion.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_chat_completion_init_model_id_override(mock_client, bedrock_unit_test_env, model_id) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service"""
|
||||
bedrock_chat_completion = BedrockChatCompletion(model_id=model_id)
|
||||
|
||||
assert bedrock_chat_completion.ai_model_id == model_id
|
||||
assert bedrock_chat_completion.service_id == model_id
|
||||
|
||||
assert bedrock_chat_completion.bedrock_client is not None
|
||||
assert bedrock_chat_completion.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_chat_completion_init_custom_service_id(mock_client, bedrock_unit_test_env, service_id) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service"""
|
||||
bedrock_chat_completion = BedrockChatCompletion(service_id=service_id)
|
||||
|
||||
assert bedrock_chat_completion.service_id == service_id
|
||||
|
||||
assert bedrock_chat_completion.bedrock_client is not None
|
||||
assert bedrock_chat_completion.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
def test_bedrock_chat_completion_init_custom_clients(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service"""
|
||||
bedrock_chat_completion = BedrockChatCompletion(
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
assert isinstance(bedrock_chat_completion.bedrock_client, MockBedrockClient)
|
||||
assert isinstance(bedrock_chat_completion.bedrock_runtime_client, MockBedrockRuntimeClient)
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_chat_completion_init_custom_client(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service"""
|
||||
bedrock_chat_completion = BedrockChatCompletion(
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
assert isinstance(bedrock_chat_completion.bedrock_client, MockBedrockClient)
|
||||
assert bedrock_chat_completion.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_chat_completion_init_custom_runtime_client(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service"""
|
||||
bedrock_chat_completion = BedrockChatCompletion(
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
)
|
||||
|
||||
assert bedrock_chat_completion.bedrock_client is not None
|
||||
assert isinstance(bedrock_chat_completion.bedrock_runtime_client, MockBedrockRuntimeClient)
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_chat_completion_init_custom_bedrock_model_provider(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service"""
|
||||
bedrock_chat_completion = BedrockChatCompletion(
|
||||
model_provider=BedrockModelProvider.AMAZON,
|
||||
)
|
||||
|
||||
assert bedrock_chat_completion.bedrock_model_provider == BedrockModelProvider.AMAZON
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["BEDROCK_CHAT_MODEL_ID"]], indirect=True)
|
||||
def test_bedrock_chat_completion_client_init_with_empty_model_id(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service with empty model id"""
|
||||
with pytest.raises(ServiceInitializationError, match="The Amazon Bedrock Chat Model ID is missing."):
|
||||
BedrockChatCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
def test_bedrock_chat_completion_client_init_invalid_settings(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service with invalid settings"""
|
||||
with pytest.raises(
|
||||
ServiceInitializationError, match="Failed to initialize the Amazon Bedrock Chat Completion Service."
|
||||
):
|
||||
BedrockChatCompletion(model_id=123) # Model ID must be a string
|
||||
|
||||
|
||||
def test_bedrock_chat_completion_client_init_invalid_model_provider(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Chat Completion service with invalid settings"""
|
||||
with pytest.raises(
|
||||
ServiceInitializationError, match="Failed to initialize the Amazon Bedrock Chat Completion Service."
|
||||
):
|
||||
BedrockChatCompletion(model_provider="invalid_provider")
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_prompt_execution_settings_class(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test getting prompt execution settings class"""
|
||||
bedrock_completion_client = BedrockChatCompletion()
|
||||
assert bedrock_completion_client.get_prompt_execution_settings_class() == BedrockChatPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region private methods
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_prepare_chat_history_for_request(mock_client, bedrock_unit_test_env, chat_history) -> None:
|
||||
"""Test preparing chat history for request"""
|
||||
bedrock_chat_completion = BedrockChatCompletion()
|
||||
parsed_chat_history = bedrock_chat_completion._prepare_chat_history_for_request(chat_history)
|
||||
|
||||
assert isinstance(parsed_chat_history, list)
|
||||
assert len(parsed_chat_history) == len(chat_history) - 2 # Exclude system message
|
||||
assert all([item["role"] in ["user", "assistant"] for item in parsed_chat_history])
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_prepare_system_message_for_request(mock_client, bedrock_unit_test_env, chat_history) -> None:
|
||||
"""Test preparing system message for request"""
|
||||
bedrock_chat_completion = BedrockChatCompletion()
|
||||
parsed_system_message = bedrock_chat_completion._prepare_system_messages_for_request(chat_history)
|
||||
|
||||
assert isinstance(parsed_system_message, list)
|
||||
assert len(parsed_system_message) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id",
|
||||
[
|
||||
"amazon.titan",
|
||||
"anthropic.claude",
|
||||
"cohere.command",
|
||||
"ai21.jamba",
|
||||
"meta.llama",
|
||||
"mistral.ai",
|
||||
],
|
||||
)
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_prepare_settings_for_request(mock_client, model_id, chat_history) -> None:
|
||||
"""Test preparing settings for request"""
|
||||
bedrock_chat_completion = BedrockChatCompletion(model_id=model_id)
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
parsed_settings = bedrock_chat_completion._prepare_settings_for_request(chat_history, settings)
|
||||
|
||||
assert isinstance(parsed_settings, dict)
|
||||
assert parsed_settings["modelId"] == bedrock_chat_completion.ai_model_id
|
||||
assert parsed_settings["messages"] == bedrock_chat_completion._prepare_chat_history_for_request(chat_history)
|
||||
assert parsed_settings["system"] == bedrock_chat_completion._prepare_system_messages_for_request(chat_history)
|
||||
assert isinstance(parsed_settings["inferenceConfig"], dict)
|
||||
assert all([parsed_settings["inferenceConfig"].values()])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id",
|
||||
[
|
||||
"arn:aws:bedrock:us-east-1:972143716085:application-inference-profile/123456",
|
||||
],
|
||||
)
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_prepare_settings_for_request_with_application_inference_profile(mock_client, model_id, chat_history) -> None:
|
||||
"""Test preparing settings for request"""
|
||||
# Without a valid model provider, it should raise an error
|
||||
bedrock_chat_completion = BedrockChatCompletion(model_id=model_id)
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=f"Model ID {model_id} does not contain a valid model provider name.",
|
||||
):
|
||||
bedrock_chat_completion._prepare_settings_for_request(chat_history, settings)
|
||||
|
||||
# With a valid model provider, it should not raise an error
|
||||
bedrock_chat_completion = BedrockChatCompletion(model_id=model_id, model_provider=BedrockModelProvider.AMAZON)
|
||||
parsed_settings = bedrock_chat_completion._prepare_settings_for_request(chat_history, settings)
|
||||
|
||||
assert isinstance(parsed_settings, dict)
|
||||
assert parsed_settings["modelId"] == bedrock_chat_completion.ai_model_id
|
||||
assert parsed_settings["messages"] == bedrock_chat_completion._prepare_chat_history_for_request(chat_history)
|
||||
assert parsed_settings["system"] == bedrock_chat_completion._prepare_system_messages_for_request(chat_history)
|
||||
assert isinstance(parsed_settings["inferenceConfig"], dict)
|
||||
assert all([parsed_settings["inferenceConfig"].values()])
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region chat completion
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"amazon.titan",
|
||||
"anthropic.claude",
|
||||
"cohere.command",
|
||||
"ai21.jamba",
|
||||
"meta.llama",
|
||||
"mistral.ai",
|
||||
],
|
||||
)
|
||||
async def test_bedrock_chat_completion(
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_bedrock_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test Amazon Bedrock Chat Completion complete method"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient, "converse", return_value=mock_bedrock_chat_completion_response
|
||||
) as mock_converse:
|
||||
# Setup
|
||||
bedrock_chat_completion = BedrockChatCompletion(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
# Act
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
response = await bedrock_chat_completion.get_chat_message_contents(chat_history=chat_history, settings=settings)
|
||||
|
||||
# Assert
|
||||
mock_converse.assert_called_once_with(
|
||||
**(bedrock_chat_completion._prepare_settings_for_request(chat_history, settings))
|
||||
)
|
||||
|
||||
assert isinstance(response, list)
|
||||
assert len(response) == 1
|
||||
assert isinstance(response[0], ChatMessageContent)
|
||||
assert response[0].ai_model_id == model_id
|
||||
assert response[0].role == AuthorRole.ASSISTANT
|
||||
assert len(response[0].items) == 1
|
||||
assert isinstance(response[0].items[0], TextContent)
|
||||
assert response[0].finish_reason == FinishReason.STOP
|
||||
assert response[0].metadata["usage"] == CompletionUsage(
|
||||
prompt_tokens=mock_bedrock_chat_completion_response["usage"]["inputTokens"],
|
||||
completion_tokens=mock_bedrock_chat_completion_response["usage"]["outputTokens"],
|
||||
)
|
||||
assert (
|
||||
response[0].items[0].text
|
||||
== mock_bedrock_chat_completion_response["output"]["message"]["content"][0]["text"]
|
||||
)
|
||||
assert response[0].inner_content == mock_bedrock_chat_completion_response
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"amazon.titan",
|
||||
"anthropic.claude",
|
||||
"cohere.command",
|
||||
"ai21.jamba",
|
||||
"meta.llama",
|
||||
"mistral.ai",
|
||||
],
|
||||
)
|
||||
async def test_bedrock_streaming_chat_completion(
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_bedrock_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test Amazon Bedrock Streaming Chat Completion complete method"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient, "converse_stream", return_value=mock_bedrock_streaming_chat_completion_response
|
||||
) as mock_converse_stream:
|
||||
# Setup
|
||||
bedrock_chat_completion = BedrockChatCompletion(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
# Act
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
chunks: list[StreamingChatMessageContent] = []
|
||||
async for streaming_messages in bedrock_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history, settings=settings
|
||||
):
|
||||
chunks.extend(streaming_messages)
|
||||
response = reduce(lambda p, r: p + r, chunks)
|
||||
|
||||
# Assert
|
||||
mock_converse_stream.assert_called_once_with(
|
||||
**(bedrock_chat_completion._prepare_settings_for_request(chat_history, settings))
|
||||
)
|
||||
|
||||
assert isinstance(response, StreamingChatMessageContent)
|
||||
assert response.ai_model_id == model_id
|
||||
assert response.role == AuthorRole.ASSISTANT
|
||||
assert len(response.items) == 1
|
||||
assert isinstance(response.inner_content, list)
|
||||
assert len(response.inner_content) == 7
|
||||
assert response.finish_reason == FinishReason.STOP
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"amazon.titan",
|
||||
"anthropic.claude",
|
||||
"cohere.command",
|
||||
"ai21.jamba",
|
||||
"meta.llama",
|
||||
"mistral.ai",
|
||||
],
|
||||
)
|
||||
async def test_bedrock_streaming_chat_completion_invalid_event(
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_bedrock_streaming_chat_completion_invalid_response,
|
||||
) -> None:
|
||||
"""Test Amazon Bedrock Streaming Chat Completion complete method"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient,
|
||||
"converse_stream",
|
||||
return_value=mock_bedrock_streaming_chat_completion_invalid_response,
|
||||
):
|
||||
# Setup
|
||||
bedrock_chat_completion = BedrockChatCompletion(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
# Act
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
with pytest.raises(ServiceInvalidResponseError):
|
||||
async for chunk in bedrock_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history, settings=settings
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.bedrock.bedrock_prompt_execution_settings import BedrockChatPromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.bedrock.services.model_provider.bedrock_model_provider import (
|
||||
BedrockModelProvider,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.bedrock.services.model_provider.utils import (
|
||||
MESSAGE_CONVERTERS,
|
||||
finish_reason_from_bedrock_to_semantic_kernel,
|
||||
remove_none_recursively,
|
||||
update_settings_from_function_choice_configuration,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.function_call_choice_configuration import FunctionCallChoiceConfiguration
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.connectors.ai.function_choice_type import FunctionChoiceType
|
||||
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.image_content import ImageContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.contents.utils.finish_reason import FinishReason
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInvalidRequestError
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
def test_remove_none_recursively():
|
||||
data = {
|
||||
"a": 1,
|
||||
"b": None,
|
||||
"c": {
|
||||
"d": 2,
|
||||
"e": None,
|
||||
"f": {
|
||||
"g": 3,
|
||||
"h": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
expected = {
|
||||
"a": 1,
|
||||
"c": {
|
||||
"d": 2,
|
||||
"f": {
|
||||
"g": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
assert remove_none_recursively(data) == expected
|
||||
|
||||
|
||||
def test_remove_recursively_max_depth():
|
||||
data = {
|
||||
"a": {"b": None},
|
||||
}
|
||||
|
||||
assert remove_none_recursively(data, max_depth=1) == data
|
||||
|
||||
|
||||
def test_update_settings_from_function_choice_configuration_auto(kernel: Kernel, custom_plugin_class) -> None:
|
||||
kernel.add_plugin(plugin=custom_plugin_class(), plugin_name="custom_plugin")
|
||||
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
|
||||
auto_function_choice_behavior = FunctionChoiceBehavior.Auto()
|
||||
auto_function_choice_behavior.configure(
|
||||
kernel,
|
||||
update_settings_from_function_choice_configuration,
|
||||
settings,
|
||||
)
|
||||
|
||||
assert "auto" in settings.tool_choice
|
||||
assert len(settings.tools) == 1
|
||||
|
||||
|
||||
def test_update_settings_from_function_choice_configuration_auto_without_plugin(kernel: Kernel) -> None:
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
|
||||
auto_function_choice_behavior = FunctionChoiceBehavior.Auto()
|
||||
auto_function_choice_behavior.configure(
|
||||
kernel,
|
||||
update_settings_from_function_choice_configuration,
|
||||
settings,
|
||||
)
|
||||
|
||||
assert settings.tool_choice is None
|
||||
assert settings.tools is None
|
||||
|
||||
|
||||
def test_update_settings_from_function_choice_configuration_none(kernel: Kernel) -> None:
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
|
||||
auto_function_choice_behavior = FunctionChoiceBehavior.NoneInvoke()
|
||||
auto_function_choice_behavior.configure(
|
||||
kernel,
|
||||
update_settings_from_function_choice_configuration,
|
||||
settings,
|
||||
)
|
||||
|
||||
assert settings.tool_choice is None
|
||||
assert settings.tools is None
|
||||
|
||||
|
||||
def test_update_settings_from_function_choice_configuration_required_with_one_function(
|
||||
kernel: Kernel,
|
||||
custom_plugin_class,
|
||||
) -> None:
|
||||
kernel.add_plugin(plugin=custom_plugin_class(), plugin_name="custom_plugin")
|
||||
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
|
||||
auto_function_choice_behavior = FunctionChoiceBehavior.Required()
|
||||
auto_function_choice_behavior.configure(
|
||||
kernel,
|
||||
update_settings_from_function_choice_configuration,
|
||||
settings,
|
||||
)
|
||||
|
||||
assert "tool" in settings.tool_choice
|
||||
assert len(settings.tools) == 1
|
||||
|
||||
|
||||
def test_update_settings_from_function_choice_configuration_required_with_more_than_one_functions(
|
||||
kernel: Kernel,
|
||||
custom_plugin_class,
|
||||
experimental_plugin_class,
|
||||
) -> None:
|
||||
kernel.add_plugin(plugin=custom_plugin_class(), plugin_name="custom_plugin")
|
||||
kernel.add_plugin(plugin=experimental_plugin_class(), plugin_name="experimental_plugin")
|
||||
|
||||
settings = BedrockChatPromptExecutionSettings()
|
||||
|
||||
auto_function_choice_behavior = FunctionChoiceBehavior.Required()
|
||||
auto_function_choice_behavior.configure(
|
||||
kernel,
|
||||
update_settings_from_function_choice_configuration,
|
||||
settings,
|
||||
)
|
||||
|
||||
assert "any" in settings.tool_choice
|
||||
assert len(settings.tools) == 2
|
||||
|
||||
|
||||
def test_inference_profile_with_bedrock_model() -> None:
|
||||
"""Test the BedrockModelProvider class returns the correct model for a given inference profile."""
|
||||
|
||||
us_amazon_inference_profile = "us.amazon.nova-lite-v1:0"
|
||||
assert BedrockModelProvider.to_model_provider(us_amazon_inference_profile) == BedrockModelProvider.AMAZON
|
||||
|
||||
us_anthropic_inference_profile = "us.anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
assert BedrockModelProvider.to_model_provider(us_anthropic_inference_profile) == BedrockModelProvider.ANTHROPIC
|
||||
|
||||
eu_meta_inference_profile = "eu.meta.llama3-2-3b-instruct-v1:0"
|
||||
assert BedrockModelProvider.to_model_provider(eu_meta_inference_profile) == BedrockModelProvider.META
|
||||
|
||||
unknown_inference_profile = "unknown"
|
||||
with pytest.raises(ValueError, match="Model ID unknown does not contain a valid model provider name."):
|
||||
BedrockModelProvider.to_model_provider(unknown_inference_profile)
|
||||
|
||||
|
||||
def test_remove_none_recursively_empty_dict() -> None:
|
||||
"""Test that an empty dict returns an empty dict."""
|
||||
assert remove_none_recursively({}) == {}
|
||||
|
||||
|
||||
def test_remove_none_recursively_no_none() -> None:
|
||||
"""Test that a dict with no None values remains the same."""
|
||||
original = {"a": 1, "b": 2}
|
||||
result = remove_none_recursively(original)
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
|
||||
def test_remove_none_recursively_with_none() -> None:
|
||||
"""Test that dict values of None are removed."""
|
||||
original = {"a": 1, "b": None, "c": {"d": None, "e": 3}}
|
||||
result = remove_none_recursively(original)
|
||||
# 'b' should be removed and 'd' inside nested dict should be removed
|
||||
assert result == {"a": 1, "c": {"e": 3}}
|
||||
|
||||
|
||||
def test_remove_none_recursively_max_depth() -> None:
|
||||
"""Test that the function respects max_depth."""
|
||||
original = {"a": {"b": {"c": None}}}
|
||||
# If max_depth=1, it won't go deep enough to remove 'c'.
|
||||
result = remove_none_recursively(original, max_depth=1)
|
||||
assert result == {"a": {"b": {"c": None}}}
|
||||
|
||||
# If max_depth=3, it should remove 'c'.
|
||||
result = remove_none_recursively(original, max_depth=3)
|
||||
assert result == {"a": {"b": {}}}
|
||||
|
||||
|
||||
def test_format_system_message() -> None:
|
||||
"""Test that system message is formatted correctly."""
|
||||
content = ChatMessageContent(role=AuthorRole.SYSTEM, content="System message")
|
||||
formatted = MESSAGE_CONVERTERS[AuthorRole.SYSTEM](content)
|
||||
assert formatted == {"text": "System message"}
|
||||
|
||||
|
||||
def test_format_user_message_text_only() -> None:
|
||||
"""Test user message with only text content."""
|
||||
text_item = TextContent(text="Hello!")
|
||||
user_message = ChatMessageContent(role=AuthorRole.USER, items=[text_item])
|
||||
|
||||
formatted = MESSAGE_CONVERTERS[AuthorRole.USER](user_message)
|
||||
assert formatted["role"] == "user"
|
||||
assert len(formatted["content"]) == 1
|
||||
assert formatted["content"][0] == {"text": "Hello!"}
|
||||
|
||||
|
||||
def test_format_user_message_image_only() -> None:
|
||||
"""Test user message with only image content."""
|
||||
img_item = ImageContent(data=b"abc", mime_type="image/png")
|
||||
user_message = ChatMessageContent(role=AuthorRole.USER, items=[img_item])
|
||||
|
||||
formatted = MESSAGE_CONVERTERS[AuthorRole.USER](user_message)
|
||||
assert formatted["role"] == "user"
|
||||
assert len(formatted["content"]) == 1
|
||||
image_section = formatted["content"][0].get("image")
|
||||
assert image_section["format"] == "png"
|
||||
assert image_section["source"]["bytes"] == b"abc"
|
||||
|
||||
|
||||
def test_format_user_message_unsupported_content() -> None:
|
||||
"""Test user message raises error with unsupported content type."""
|
||||
# We can simulate an unsupported content type by using FunctionCallContent.
|
||||
func_call_item = FunctionCallContent(id="123", function_name="test_function", arguments="{}")
|
||||
user_message = ChatMessageContent(role=AuthorRole.USER, items=[func_call_item])
|
||||
|
||||
with pytest.raises(ServiceInvalidRequestError) as exc:
|
||||
MESSAGE_CONVERTERS[AuthorRole.USER](user_message)
|
||||
|
||||
assert "Only text and image content are supported in a user message." in str(exc.value)
|
||||
|
||||
|
||||
def test_format_assistant_message_text_content() -> None:
|
||||
"""Test assistant message with text content."""
|
||||
text_item = TextContent(text="Assistant response")
|
||||
assistant_message = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[text_item])
|
||||
|
||||
formatted = MESSAGE_CONVERTERS[AuthorRole.ASSISTANT](assistant_message)
|
||||
assert formatted["role"] == "assistant"
|
||||
assert formatted["content"] == [{"text": "Assistant response"}]
|
||||
|
||||
|
||||
def test_format_assistant_message_function_call_content() -> None:
|
||||
"""Test assistant message with function call content."""
|
||||
func_item = FunctionCallContent(
|
||||
id="fc1", plugin_name="plugin", function_name="function", arguments='{"param": "value"}'
|
||||
)
|
||||
assistant_message = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[func_item])
|
||||
|
||||
formatted = MESSAGE_CONVERTERS[AuthorRole.ASSISTANT](assistant_message)
|
||||
assert len(formatted["content"]) == 1
|
||||
tool_use = formatted["content"][0].get("toolUse")
|
||||
assert tool_use
|
||||
assert tool_use["toolUseId"] == "fc1"
|
||||
assert tool_use["name"] == "plugin-function"
|
||||
assert tool_use["input"] == {"param": "value"}
|
||||
|
||||
|
||||
def test_format_assistant_message_image_content_raises() -> None:
|
||||
"""Test assistant message with image raises error."""
|
||||
img_item = ImageContent(data=b"abc", mime_type="image/jpeg")
|
||||
assistant_message = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[img_item])
|
||||
|
||||
with pytest.raises(ServiceInvalidRequestError) as exc:
|
||||
MESSAGE_CONVERTERS[AuthorRole.ASSISTANT](assistant_message)
|
||||
|
||||
assert "Image content is not supported in an assistant message." in str(exc.value)
|
||||
|
||||
|
||||
def test_format_assistant_message_unsupported_type() -> None:
|
||||
"""Test assistant message with unsupported item content type."""
|
||||
func_res_item = FunctionResultContent(id="res1", function_name="some_function", result="some_result")
|
||||
assistant_message = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[func_res_item])
|
||||
|
||||
with pytest.raises(ServiceInvalidRequestError) as exc:
|
||||
MESSAGE_CONVERTERS[AuthorRole.ASSISTANT](assistant_message)
|
||||
assert "Unsupported content type in an assistant message:" in str(exc.value)
|
||||
|
||||
|
||||
def test_format_tool_message_text() -> None:
|
||||
"""Test tool message with text content."""
|
||||
text_item = TextContent(text="Some text")
|
||||
tool_message = ChatMessageContent(role=AuthorRole.TOOL, items=[text_item])
|
||||
|
||||
formatted = MESSAGE_CONVERTERS[AuthorRole.TOOL](tool_message)
|
||||
assert formatted["role"] == "user" # note that for a tool message, role set to 'user'
|
||||
assert formatted["content"] == [{"text": "Some text"}]
|
||||
|
||||
|
||||
def test_format_tool_message_function_result() -> None:
|
||||
"""Test tool message with function result content."""
|
||||
func_result_item = FunctionResultContent(id="res_id", function_name="test_function", result="some result")
|
||||
tool_message = ChatMessageContent(role=AuthorRole.TOOL, items=[func_result_item])
|
||||
|
||||
formatted = MESSAGE_CONVERTERS[AuthorRole.TOOL](tool_message)
|
||||
assert formatted["role"] == "user"
|
||||
content = formatted["content"][0]
|
||||
assert content.get("toolResult")
|
||||
assert content["toolResult"]["toolUseId"] == "res_id"
|
||||
assert content["toolResult"]["content"] == [{"text": "some result"}]
|
||||
|
||||
|
||||
def test_format_tool_message_image_raises() -> None:
|
||||
"""Test tool message with image content raises an error."""
|
||||
img_item = ImageContent(data=b"xyz", mime_type="image/jpeg")
|
||||
tool_message = ChatMessageContent(role=AuthorRole.TOOL, items=[img_item])
|
||||
|
||||
with pytest.raises(ServiceInvalidRequestError) as exc:
|
||||
MESSAGE_CONVERTERS[AuthorRole.TOOL](tool_message)
|
||||
assert "Image content is not supported in a tool message." in str(exc.value)
|
||||
|
||||
|
||||
def test_finish_reason_from_bedrock_to_semantic_kernel_stop() -> None:
|
||||
"""Test that 'stop_sequence' maps to FinishReason.STOP"""
|
||||
reason = finish_reason_from_bedrock_to_semantic_kernel("stop_sequence")
|
||||
assert reason == FinishReason.STOP
|
||||
|
||||
reason = finish_reason_from_bedrock_to_semantic_kernel("end_turn")
|
||||
assert reason == FinishReason.STOP
|
||||
|
||||
|
||||
def test_finish_reason_from_bedrock_to_semantic_kernel_length() -> None:
|
||||
"""Test that 'max_tokens' maps to FinishReason.LENGTH"""
|
||||
reason = finish_reason_from_bedrock_to_semantic_kernel("max_tokens")
|
||||
assert reason == FinishReason.LENGTH
|
||||
|
||||
|
||||
def test_finish_reason_from_bedrock_to_semantic_kernel_content_filtered() -> None:
|
||||
"""Test that 'content_filtered' maps to FinishReason.CONTENT_FILTER"""
|
||||
reason = finish_reason_from_bedrock_to_semantic_kernel("content_filtered")
|
||||
assert reason == FinishReason.CONTENT_FILTER
|
||||
|
||||
|
||||
def test_finish_reason_from_bedrock_to_semantic_kernel_tool_use() -> None:
|
||||
"""Test that 'tool_use' maps to FinishReason.TOOL_CALLS"""
|
||||
reason = finish_reason_from_bedrock_to_semantic_kernel("tool_use")
|
||||
assert reason == FinishReason.TOOL_CALLS
|
||||
|
||||
|
||||
def test_finish_reason_from_bedrock_to_semantic_kernel_unknown() -> None:
|
||||
"""Test that unknown finish reason returns None"""
|
||||
reason = finish_reason_from_bedrock_to_semantic_kernel("something_unknown")
|
||||
assert reason is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bedrock_settings() -> BedrockChatPromptExecutionSettings:
|
||||
"""Helper fixture for BedrockChatPromptExecutionSettings."""
|
||||
return BedrockChatPromptExecutionSettings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_function_choice_config() -> FunctionCallChoiceConfiguration:
|
||||
"""Helper fixture for a sample FunctionCallChoiceConfiguration."""
|
||||
|
||||
# We'll create mock kernel functions with metadata
|
||||
mock_func_1 = MagicMock()
|
||||
mock_func_1.fully_qualified_name = "plugin-function1"
|
||||
mock_func_1.description = "Function 1 description"
|
||||
|
||||
param1 = MagicMock()
|
||||
param1.name = "param1"
|
||||
param1.schema_data = {"type": "string"}
|
||||
param1.is_required = True
|
||||
|
||||
param2 = MagicMock()
|
||||
param2.name = "param2"
|
||||
param2.schema_data = {"type": "integer"}
|
||||
param2.is_required = False
|
||||
|
||||
mock_func_1.parameters = [
|
||||
param1,
|
||||
param2,
|
||||
]
|
||||
mock_func_2 = MagicMock()
|
||||
mock_func_2.fully_qualified_name = "plugin-function2"
|
||||
mock_func_2.description = "Function 2 description"
|
||||
mock_func_2.parameters = []
|
||||
|
||||
config = FunctionCallChoiceConfiguration()
|
||||
config.available_functions = [mock_func_1, mock_func_2]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def test_update_settings_from_function_choice_configuration_none_type(
|
||||
mock_function_choice_config, mock_bedrock_settings
|
||||
) -> None:
|
||||
"""Test that if the FunctionChoiceType is NONE it doesn't modify settings."""
|
||||
update_settings_from_function_choice_configuration(
|
||||
mock_function_choice_config, mock_bedrock_settings, FunctionChoiceType.NONE
|
||||
)
|
||||
assert mock_bedrock_settings.tool_choice is None
|
||||
assert mock_bedrock_settings.tools is None
|
||||
|
||||
|
||||
def test_update_settings_from_function_choice_configuration_auto_two_tools(
|
||||
mock_function_choice_config, mock_bedrock_settings
|
||||
) -> None:
|
||||
"""Test that AUTO sets tool_choice to {"auto": {}} and sets tools list"""
|
||||
update_settings_from_function_choice_configuration(
|
||||
mock_function_choice_config, mock_bedrock_settings, FunctionChoiceType.AUTO
|
||||
)
|
||||
assert mock_bedrock_settings.tool_choice == {"auto": {}}
|
||||
assert len(mock_bedrock_settings.tools) == 2
|
||||
# Validate structure of first tool
|
||||
tool_spec_1 = mock_bedrock_settings.tools[0].get("toolSpec")
|
||||
assert tool_spec_1["name"] == "plugin-function1"
|
||||
assert tool_spec_1["description"] == "Function 1 description"
|
||||
|
||||
|
||||
def test_update_settings_from_function_choice_configuration_required_many(
|
||||
mock_function_choice_config, mock_bedrock_settings
|
||||
) -> None:
|
||||
"""Test that REQUIRED with more than one function sets tool_choice to {"any": {}}."""
|
||||
update_settings_from_function_choice_configuration(
|
||||
mock_function_choice_config, mock_bedrock_settings, FunctionChoiceType.REQUIRED
|
||||
)
|
||||
assert mock_bedrock_settings.tool_choice == {"any": {}}
|
||||
assert len(mock_bedrock_settings.tools) == 2
|
||||
|
||||
|
||||
def test_update_settings_from_function_choice_configuration_required_one(mock_bedrock_settings) -> None:
|
||||
"""Test that REQUIRED with a single function picks "tool" with that function name."""
|
||||
single_func = MagicMock()
|
||||
single_func.fully_qualified_name = "plugin-function"
|
||||
single_func.description = "Only function"
|
||||
single_func.parameters = []
|
||||
|
||||
config = FunctionCallChoiceConfiguration()
|
||||
config.available_functions = [single_func]
|
||||
|
||||
update_settings_from_function_choice_configuration(config, mock_bedrock_settings, FunctionChoiceType.REQUIRED)
|
||||
assert mock_bedrock_settings.tool_choice == {"tool": {"name": "plugin-function"}}
|
||||
assert len(mock_bedrock_settings.tools) == 1
|
||||
assert mock_bedrock_settings.tools[0]["toolSpec"]["name"] == "plugin-function"
|
||||
@@ -0,0 +1,324 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from functools import reduce
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.bedrock.bedrock_prompt_execution_settings import BedrockTextPromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.bedrock.services.bedrock_text_completion import BedrockTextCompletion
|
||||
from semantic_kernel.connectors.ai.bedrock.services.model_provider.bedrock_model_provider import (
|
||||
BedrockModelProvider,
|
||||
get_text_completion_request_body,
|
||||
)
|
||||
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
from tests.unit.connectors.ai.bedrock.conftest import MockBedrockClient, MockBedrockRuntimeClient
|
||||
|
||||
# region init
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_completion_init(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service"""
|
||||
bedrock_text_completion = BedrockTextCompletion()
|
||||
|
||||
assert bedrock_text_completion.ai_model_id == bedrock_unit_test_env["BEDROCK_TEXT_MODEL_ID"]
|
||||
assert bedrock_text_completion.service_id == bedrock_unit_test_env["BEDROCK_TEXT_MODEL_ID"]
|
||||
|
||||
assert bedrock_text_completion.bedrock_model_provider == BedrockModelProvider(
|
||||
bedrock_unit_test_env["BEDROCK_MODEL_PROVIDER"]
|
||||
)
|
||||
assert bedrock_text_completion.bedrock_client is not None
|
||||
assert bedrock_text_completion.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_completion_init_model_id_override(mock_client, bedrock_unit_test_env, model_id) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service"""
|
||||
bedrock_text_completion = BedrockTextCompletion(model_id=model_id)
|
||||
|
||||
assert bedrock_text_completion.ai_model_id == model_id
|
||||
assert bedrock_text_completion.service_id == model_id
|
||||
|
||||
assert bedrock_text_completion.bedrock_client is not None
|
||||
assert bedrock_text_completion.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_completion_init_custom_service_id(mock_client, bedrock_unit_test_env, service_id) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service"""
|
||||
bedrock_text_completion = BedrockTextCompletion(service_id=service_id)
|
||||
|
||||
assert bedrock_text_completion.service_id == service_id
|
||||
|
||||
assert bedrock_text_completion.bedrock_client is not None
|
||||
assert bedrock_text_completion.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
def test_bedrock_text_completion_init_custom_clients(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service"""
|
||||
bedrock_text_completion = BedrockTextCompletion(
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
assert isinstance(bedrock_text_completion.bedrock_client, MockBedrockClient)
|
||||
assert isinstance(bedrock_text_completion.bedrock_runtime_client, MockBedrockRuntimeClient)
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_completion_init_custom_client(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service"""
|
||||
bedrock_text_completion = BedrockTextCompletion(
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
assert isinstance(bedrock_text_completion.bedrock_client, MockBedrockClient)
|
||||
assert bedrock_text_completion.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_completion_init_custom_runtime_client(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service"""
|
||||
bedrock_text_completion = BedrockTextCompletion(
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
)
|
||||
|
||||
assert bedrock_text_completion.bedrock_client is not None
|
||||
assert isinstance(bedrock_text_completion.bedrock_runtime_client, MockBedrockRuntimeClient)
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_completion_init_custom_bedrock_model_provider(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service"""
|
||||
bedrock_text_completion = BedrockTextCompletion(
|
||||
model_provider=BedrockModelProvider.AMAZON,
|
||||
)
|
||||
|
||||
assert bedrock_text_completion.bedrock_model_provider == BedrockModelProvider.AMAZON
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["BEDROCK_TEXT_MODEL_ID"]], indirect=True)
|
||||
def test_bedrock_text_completion_client_init_with_empty_model_id(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service with empty model id"""
|
||||
with pytest.raises(ServiceInitializationError, match="The Amazon Bedrock Text Model ID is missing."):
|
||||
BedrockTextCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
def test_bedrock_text_completion_client_init_invalid_settings(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service with invalid settings"""
|
||||
with pytest.raises(
|
||||
ServiceInitializationError, match="Failed to initialize the Amazon Bedrock Text Completion Service."
|
||||
):
|
||||
BedrockTextCompletion(model_id=123) # Model ID must be a string
|
||||
|
||||
|
||||
def test_bedrock_text_completion_client_init_invalid_model_provider(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Completion service with invalid settings"""
|
||||
with pytest.raises(
|
||||
ServiceInitializationError, match="Failed to initialize the Amazon Bedrock Text Completion Service."
|
||||
):
|
||||
BedrockTextCompletion(model_provider="invalid_provider")
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_prompt_execution_settings_class(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test getting prompt execution settings class"""
|
||||
bedrock_completion_client = BedrockTextCompletion()
|
||||
assert bedrock_completion_client.get_prompt_execution_settings_class() == BedrockTextPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region text completion
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"amazon.titan",
|
||||
"anthropic.claude",
|
||||
"cohere.command",
|
||||
"ai21.jamba",
|
||||
"meta.llama",
|
||||
"mistral.ai",
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_bedrock_text_completion(
|
||||
model_id,
|
||||
mock_bedrock_text_completion_response,
|
||||
output_text,
|
||||
) -> None:
|
||||
"""Test Amazon Bedrock Text Completion complete method"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient, "invoke_model", return_value=mock_bedrock_text_completion_response
|
||||
) as mock_model_invoke:
|
||||
# Setup
|
||||
bedrock_text_completion = BedrockTextCompletion(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
# Act
|
||||
settings = BedrockTextPromptExecutionSettings()
|
||||
response = await bedrock_text_completion.get_text_contents("Hello!", settings=settings)
|
||||
|
||||
# Assert
|
||||
mock_model_invoke.assert_called_once_with(
|
||||
body=json.dumps(get_text_completion_request_body(model_id, "Hello!", settings)),
|
||||
modelId=model_id,
|
||||
accept="application/json",
|
||||
contentType="application/json",
|
||||
)
|
||||
|
||||
assert isinstance(response, list)
|
||||
assert len(response) == 1
|
||||
assert isinstance(response[0], TextContent)
|
||||
assert response[0].ai_model_id == model_id
|
||||
assert response[0].text == output_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"arn:aws:bedrock:us-east-1:972143716085:application-inference-profile/123456",
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_bedrock_text_completion_with_application_inference_profile(
|
||||
model_id,
|
||||
mock_bedrock_text_completion_response,
|
||||
output_text,
|
||||
model_provider,
|
||||
) -> None:
|
||||
"""Test Amazon Bedrock Text Completion complete method"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient,
|
||||
"invoke_model",
|
||||
return_value=mock_bedrock_text_completion_response,
|
||||
) as mock_model_invoke:
|
||||
# Setup
|
||||
bedrock_text_completion = BedrockTextCompletion(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
model_provider=model_provider,
|
||||
)
|
||||
|
||||
# Act
|
||||
settings = BedrockTextPromptExecutionSettings()
|
||||
await bedrock_text_completion.get_text_contents("Hello!", settings=settings)
|
||||
|
||||
# Assert
|
||||
mock_model_invoke.assert_called_once_with(
|
||||
body=json.dumps(get_text_completion_request_body(model_id, "Hello!", settings, model_provider)),
|
||||
modelId=model_id,
|
||||
accept="application/json",
|
||||
contentType="application/json",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"amazon.titan",
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_bedrock_streaming_text_completion(
|
||||
model_id,
|
||||
mock_bedrock_streaming_text_completion_response,
|
||||
output_text,
|
||||
) -> None:
|
||||
"""Test Amazon Bedrock Text Completion complete method"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient,
|
||||
"invoke_model_with_response_stream",
|
||||
return_value=mock_bedrock_streaming_text_completion_response,
|
||||
) as mock_invoke_model_with_response_stream:
|
||||
# Setup
|
||||
bedrock_text_completion = BedrockTextCompletion(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
# Act
|
||||
settings = BedrockTextPromptExecutionSettings()
|
||||
chunks: list[StreamingTextContent] = []
|
||||
async for streaming_responses in bedrock_text_completion.get_streaming_text_contents(
|
||||
"Hello!", settings=settings
|
||||
):
|
||||
chunks.extend(streaming_responses)
|
||||
response = reduce(lambda p, r: p + r, chunks)
|
||||
|
||||
# Assert
|
||||
mock_invoke_model_with_response_stream.assert_called_once_with(
|
||||
body=json.dumps(get_text_completion_request_body(model_id, "Hello!", settings)),
|
||||
modelId=model_id,
|
||||
accept="application/json",
|
||||
contentType="application/json",
|
||||
)
|
||||
assert isinstance(response, StreamingTextContent)
|
||||
assert response.ai_model_id == model_id
|
||||
assert response.text == output_text
|
||||
assert response.choice_index == 0
|
||||
assert isinstance(response.inner_content, list)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"arn:aws:bedrock:us-east-1:972143716085:application-inference-profile/123456",
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_bedrock_streaming_text_completion_with_application_inference_profile(
|
||||
model_id,
|
||||
mock_bedrock_streaming_text_completion_response,
|
||||
output_text,
|
||||
model_provider,
|
||||
) -> None:
|
||||
"""Test Amazon Bedrock Chat Completion complete method"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient,
|
||||
"invoke_model_with_response_stream",
|
||||
return_value=mock_bedrock_streaming_text_completion_response,
|
||||
) as mock_invoke_model_with_response_stream:
|
||||
# Setup
|
||||
bedrock_text_completion = BedrockTextCompletion(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
model_provider=model_provider,
|
||||
)
|
||||
|
||||
# Act
|
||||
settings = BedrockTextPromptExecutionSettings()
|
||||
chunks: list[StreamingTextContent] = []
|
||||
async for streaming_responses in bedrock_text_completion.get_streaming_text_contents(
|
||||
"Hello!", settings=settings
|
||||
):
|
||||
chunks.extend(streaming_responses)
|
||||
|
||||
# Assert
|
||||
mock_invoke_model_with_response_stream.assert_called_once_with(
|
||||
body=json.dumps(get_text_completion_request_body(model_id, "Hello!", settings, model_provider)),
|
||||
modelId=model_id,
|
||||
accept="application/json",
|
||||
contentType="application/json",
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import ANY, Mock, patch
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.bedrock.bedrock_prompt_execution_settings import (
|
||||
BedrockEmbeddingPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.bedrock.services.bedrock_text_embedding import BedrockTextEmbedding
|
||||
from semantic_kernel.connectors.ai.bedrock.services.model_provider.bedrock_model_provider import BedrockModelProvider
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceInvalidResponseError
|
||||
from tests.unit.connectors.ai.bedrock.conftest import MockBedrockClient, MockBedrockRuntimeClient
|
||||
|
||||
# region init
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_embedding_init(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service"""
|
||||
bedrock_text_embedding = BedrockTextEmbedding()
|
||||
|
||||
assert bedrock_text_embedding.ai_model_id == bedrock_unit_test_env["BEDROCK_EMBEDDING_MODEL_ID"]
|
||||
assert bedrock_text_embedding.service_id == bedrock_unit_test_env["BEDROCK_EMBEDDING_MODEL_ID"]
|
||||
|
||||
assert bedrock_text_embedding.bedrock_model_provider == BedrockModelProvider(
|
||||
bedrock_unit_test_env["BEDROCK_MODEL_PROVIDER"]
|
||||
)
|
||||
assert bedrock_text_embedding.bedrock_client is not None
|
||||
assert bedrock_text_embedding.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_embedding_init_model_id_override(mock_client, bedrock_unit_test_env, model_id) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service"""
|
||||
bedrock_text_embedding = BedrockTextEmbedding(model_id=model_id)
|
||||
|
||||
assert bedrock_text_embedding.ai_model_id == model_id
|
||||
assert bedrock_text_embedding.service_id == model_id
|
||||
|
||||
assert bedrock_text_embedding.bedrock_client is not None
|
||||
assert bedrock_text_embedding.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_embedding_init_custom_service_id(mock_client, bedrock_unit_test_env, service_id) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service"""
|
||||
bedrock_text_embedding = BedrockTextEmbedding(service_id=service_id)
|
||||
|
||||
assert bedrock_text_embedding.service_id == service_id
|
||||
|
||||
assert bedrock_text_embedding.bedrock_client is not None
|
||||
assert bedrock_text_embedding.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
def test_bedrock_text_embedding_init_custom_clients(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service"""
|
||||
bedrock_text_embedding = BedrockTextEmbedding(
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
assert isinstance(bedrock_text_embedding.bedrock_client, MockBedrockClient)
|
||||
assert isinstance(bedrock_text_embedding.bedrock_runtime_client, MockBedrockRuntimeClient)
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_embedding_init_custom_client(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service"""
|
||||
bedrock_text_embedding = BedrockTextEmbedding(
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
assert isinstance(bedrock_text_embedding.bedrock_client, MockBedrockClient)
|
||||
assert bedrock_text_embedding.bedrock_runtime_client is not None
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_embedding_init_custom_runtime_client(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service"""
|
||||
bedrock_text_embedding = BedrockTextEmbedding(
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
)
|
||||
|
||||
assert bedrock_text_embedding.bedrock_client is not None
|
||||
assert isinstance(bedrock_text_embedding.bedrock_runtime_client, MockBedrockRuntimeClient)
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_bedrock_text_embedding_init_custom_bedrock_model_provider(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service"""
|
||||
bedrock_text_embedding = BedrockTextEmbedding(
|
||||
model_provider=BedrockModelProvider.AMAZON,
|
||||
)
|
||||
|
||||
assert bedrock_text_embedding.bedrock_model_provider == BedrockModelProvider.AMAZON
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["BEDROCK_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_bedrock_text_embedding_client_init_with_empty_model_id(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service with empty model id"""
|
||||
with pytest.raises(ServiceInitializationError, match="The Amazon Bedrock Text Embedding Model ID is missing."):
|
||||
BedrockTextEmbedding(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
def test_bedrock_text_embedding_client_init_invalid_settings(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service with invalid settings"""
|
||||
with pytest.raises(
|
||||
ServiceInitializationError, match="Failed to initialize the Amazon Bedrock Text Embedding Service."
|
||||
):
|
||||
BedrockTextEmbedding(model_id=123) # Model ID must be a string
|
||||
|
||||
|
||||
def test_bedrock_text_embedding_client_init_invalid_model_provider(bedrock_unit_test_env) -> None:
|
||||
"""Test initialization of Amazon Bedrock Text Embedding service with invalid settings"""
|
||||
with pytest.raises(
|
||||
ServiceInitializationError, match="Failed to initialize the Amazon Bedrock Text Embedding Service."
|
||||
):
|
||||
BedrockTextEmbedding(model_provider="invalid_provider")
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def test_prompt_execution_settings_class(mock_client, bedrock_unit_test_env) -> None:
|
||||
"""Test getting prompt execution settings class"""
|
||||
bedrock_completion_client = BedrockTextEmbedding()
|
||||
assert bedrock_completion_client.get_prompt_execution_settings_class() == BedrockEmbeddingPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"amazon.titan",
|
||||
"cohere.command",
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_bedrock_text_embedding(model_id, mock_bedrock_text_embedding_response) -> None:
|
||||
"""Test Bedrock text embedding generation"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient, "invoke_model", return_value=mock_bedrock_text_embedding_response
|
||||
) as mock_model_invoke:
|
||||
# Setup
|
||||
bedrock_text_embedding = BedrockTextEmbedding(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
# Act
|
||||
settings = BedrockEmbeddingPromptExecutionSettings()
|
||||
response = await bedrock_text_embedding.generate_embeddings(["hello", "world"], settings)
|
||||
|
||||
# Assert
|
||||
mock_model_invoke.assert_called_with(
|
||||
body=ANY,
|
||||
modelId=model_id,
|
||||
accept="application/json",
|
||||
contentType="application/json",
|
||||
)
|
||||
assert mock_model_invoke.call_count == 2
|
||||
|
||||
assert len(response) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"arn:aws:bedrock:us-east-1:972143716085:application-inference-profile/123456",
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_bedrock_text_embedding_with_application_inference_profile(
|
||||
model_id,
|
||||
mock_bedrock_text_embedding_response,
|
||||
model_provider,
|
||||
) -> None:
|
||||
"""Test Bedrock text embedding generation"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient, "invoke_model", return_value=mock_bedrock_text_embedding_response
|
||||
) as mock_model_invoke:
|
||||
# Setup
|
||||
bedrock_text_embedding = BedrockTextEmbedding(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
model_provider=BedrockModelProvider.AMAZON,
|
||||
)
|
||||
|
||||
# Act
|
||||
settings = BedrockEmbeddingPromptExecutionSettings()
|
||||
response = await bedrock_text_embedding.generate_embeddings(["hello", "world"], settings)
|
||||
|
||||
# Assert
|
||||
mock_model_invoke.assert_called_with(
|
||||
body=ANY,
|
||||
modelId=model_id,
|
||||
accept="application/json",
|
||||
contentType="application/json",
|
||||
)
|
||||
assert mock_model_invoke.call_count == 2
|
||||
|
||||
assert len(response) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
# These are fake model ids with the supported prefixes
|
||||
"model_id",
|
||||
[
|
||||
"amazon.titan",
|
||||
"cohere.command",
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_bedrock_text_embedding_with_invalid_response(
|
||||
model_id, mock_bedrock_text_embedding_invalid_response
|
||||
) -> None:
|
||||
"""Test Bedrock text embedding generation with invalid response"""
|
||||
with patch.object(
|
||||
MockBedrockRuntimeClient, "invoke_model", return_value=mock_bedrock_text_embedding_invalid_response
|
||||
):
|
||||
# Setup
|
||||
bedrock_text_embedding = BedrockTextEmbedding(
|
||||
model_id=model_id,
|
||||
runtime_client=MockBedrockRuntimeClient(),
|
||||
client=MockBedrockClient(),
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceInvalidResponseError):
|
||||
await bedrock_text_embedding.generate_embeddings(["hello", "world"])
|
||||
@@ -0,0 +1,104 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from semantic_kernel.connectors.ai.bedrock import BedrockChatPromptExecutionSettings, BedrockPromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
|
||||
|
||||
def test_default_bedrock_prompt_execution_settings():
|
||||
settings = BedrockPromptExecutionSettings()
|
||||
|
||||
assert settings.temperature is None
|
||||
assert settings.top_p is None
|
||||
assert settings.top_k is None
|
||||
assert settings.max_tokens is None
|
||||
assert settings.stop == []
|
||||
|
||||
|
||||
def test_custom_bedrock_prompt_execution_settings():
|
||||
settings = BedrockPromptExecutionSettings(
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
top_k=10,
|
||||
max_tokens=128,
|
||||
stop=["world"],
|
||||
)
|
||||
|
||||
assert settings.temperature == 0.5
|
||||
assert settings.top_p == 0.5
|
||||
assert settings.top_k == 10
|
||||
assert settings.max_tokens == 128
|
||||
assert settings.stop == ["world"]
|
||||
|
||||
|
||||
def test_bedrock_prompt_execution_settings_from_default_completion_config():
|
||||
settings = PromptExecutionSettings(service_id="test_service")
|
||||
chat_settings = BedrockChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.temperature is None
|
||||
assert chat_settings.top_p is None
|
||||
assert chat_settings.top_k is None
|
||||
assert chat_settings.max_tokens is None
|
||||
assert chat_settings.stop == []
|
||||
|
||||
|
||||
def test_bedrock_prompt_execution_settings_from_openai_prompt_execution_settings():
|
||||
chat_settings = BedrockChatPromptExecutionSettings(service_id="test_service", temperature=1.0)
|
||||
new_settings = BedrockPromptExecutionSettings(service_id="test_2", temperature=0.0)
|
||||
chat_settings.update_from_prompt_execution_settings(new_settings)
|
||||
|
||||
assert chat_settings.service_id == "test_2"
|
||||
assert chat_settings.temperature == 0.0
|
||||
|
||||
|
||||
def test_bedrock_prompt_execution_settings_from_custom_completion_config():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"top_k": 10,
|
||||
"max_tokens": 128,
|
||||
"stop": ["world"],
|
||||
},
|
||||
)
|
||||
chat_settings = BedrockChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.top_k == 10
|
||||
assert chat_settings.max_tokens == 128
|
||||
assert chat_settings.stop == ["world"]
|
||||
|
||||
|
||||
def test_bedrock_chat_prompt_execution_settings_from_custom_completion_config_with_functions():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"tools": [{"function": {}}],
|
||||
},
|
||||
)
|
||||
chat_settings = BedrockChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.tools == [{"function": {}}]
|
||||
|
||||
|
||||
def test_create_options():
|
||||
settings = BedrockPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"top_k": 10,
|
||||
"max_tokens": 128,
|
||||
"stop": ["world"],
|
||||
},
|
||||
)
|
||||
options = settings.prepare_settings_dict()
|
||||
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.5
|
||||
assert options["top_k"] == 10
|
||||
assert options["max_tokens"] == 128
|
||||
assert options["stop"] == ["world"]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def service_id() -> str:
|
||||
return "test_service_id"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def chat_history() -> ChatHistory:
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_system_message("system_prompt")
|
||||
chat_history.add_user_message("test_prompt")
|
||||
return chat_history
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def prompt() -> str:
|
||||
return "test_prompt"
|
||||
@@ -0,0 +1,174 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from google.genai.types import (
|
||||
Candidate,
|
||||
Content,
|
||||
FinishReason,
|
||||
GenerateContentResponse,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def google_ai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Google AI Unit Tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"GOOGLE_AI_GEMINI_MODEL_ID": "test-gemini-model-id",
|
||||
"GOOGLE_AI_EMBEDDING_MODEL_ID": "test-embedding-model-id",
|
||||
"GOOGLE_AI_API_KEY": "test-api-key",
|
||||
"GOOGLE_AI_CLOUD_PROJECT_ID": "test-project-id",
|
||||
"GOOGLE_AI_CLOUD_REGION": "test-region",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_google_ai_chat_completion_response() -> GenerateContentResponse:
|
||||
"""Mock Google AI Chat Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(role="user", parts=[Part.from_text(text="Test content")])
|
||||
candidate.finish_reason = FinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates = [candidate]
|
||||
response.usage_metadata = GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=0,
|
||||
cached_content_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_google_ai_chat_completion_response_with_tool_call() -> GenerateContentResponse:
|
||||
"""Mock Google AI Chat Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(
|
||||
role="user",
|
||||
parts=[
|
||||
Part.from_function_call(
|
||||
name="test_function",
|
||||
args={"test_arg": "test_value"},
|
||||
)
|
||||
],
|
||||
)
|
||||
candidate.finish_reason = FinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates = [candidate]
|
||||
response.usage_metadata = GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=0,
|
||||
cached_content_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_google_ai_streaming_chat_completion_response() -> AsyncIterator[GenerateContentResponse]:
|
||||
"""Mock Google AI streaming Chat Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(role="user", parts=[Part.from_text(text="Test content")])
|
||||
candidate.finish_reason = FinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates = [candidate]
|
||||
response.usage_metadata = GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=0,
|
||||
cached_content_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
iterable = MagicMock(spec=AsyncGenerator)
|
||||
iterable.__aiter__.return_value = [response]
|
||||
|
||||
return iterable
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_google_ai_streaming_chat_completion_response_with_tool_call() -> AsyncIterator[GenerateContentResponse]:
|
||||
"""Mock Google AI streaming Chat Completion response with tool call."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(
|
||||
role="user",
|
||||
parts=[
|
||||
Part.from_function_call(
|
||||
name="getLightStatus",
|
||||
args={"arg1": "test_value"},
|
||||
)
|
||||
],
|
||||
)
|
||||
candidate.finish_reason = FinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates = [candidate]
|
||||
response.usage_metadata = GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=0,
|
||||
cached_content_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
iterable = MagicMock(spec=AsyncGenerator)
|
||||
iterable.__aiter__.return_value = [response]
|
||||
|
||||
return iterable
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_google_ai_text_completion_response() -> GenerateContentResponse:
|
||||
"""Mock Google AI Text Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(parts=[Part.from_text(text="Test content")])
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates = [candidate]
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_google_ai_streaming_text_completion_response() -> AsyncIterator[GenerateContentResponse]:
|
||||
"""Mock Google AI streaming Text Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(parts=[Part.from_text(text="Test content")])
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates = [candidate]
|
||||
|
||||
iterable = MagicMock(spec=AsyncGenerator)
|
||||
iterable.__aiter__.return_value = [response]
|
||||
|
||||
return iterable
|
||||
+650
@@ -0,0 +1,650 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from google.genai import Client
|
||||
from google.genai.models import AsyncModels
|
||||
from google.genai.types import Content, GenerateContentConfigDict
|
||||
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.connectors.ai.google.google_ai.google_ai_prompt_execution_settings import (
|
||||
GoogleAIChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.google.google_ai.google_ai_settings import GoogleAISettings
|
||||
from semantic_kernel.connectors.ai.google.google_ai.services.google_ai_chat_completion import GoogleAIChatCompletion
|
||||
from semantic_kernel.connectors.ai.google.shared_utils import filter_system_message
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.finish_reason import FinishReason
|
||||
from semantic_kernel.exceptions.service_exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
)
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
# region init
|
||||
def test_google_ai_chat_completion_init(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAIChatCompletion"""
|
||||
model_id = google_ai_unit_test_env["GOOGLE_AI_GEMINI_MODEL_ID"]
|
||||
api_key = google_ai_unit_test_env["GOOGLE_AI_API_KEY"]
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
|
||||
assert google_ai_chat_completion.ai_model_id == model_id
|
||||
assert google_ai_chat_completion.service_id == model_id
|
||||
|
||||
assert isinstance(google_ai_chat_completion.service_settings, GoogleAISettings)
|
||||
assert google_ai_chat_completion.service_settings.gemini_model_id == model_id
|
||||
assert google_ai_chat_completion.service_settings.api_key.get_secret_value() == api_key
|
||||
|
||||
|
||||
def test_google_ai_chat_completion_init_with_service_id(google_ai_unit_test_env, service_id) -> None:
|
||||
"""Test initialization of GoogleAIChatCompletion with a service_id that is not the model_id"""
|
||||
google_ai_chat_completion = GoogleAIChatCompletion(service_id=service_id)
|
||||
|
||||
assert google_ai_chat_completion.service_id == service_id
|
||||
|
||||
|
||||
def test_google_ai_chat_completion_init_with_model_id_in_argument(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAIChatCompletion with model_id in argument"""
|
||||
google_ai_chat_completion = GoogleAIChatCompletion(gemini_model_id="custom_model_id")
|
||||
|
||||
assert google_ai_chat_completion.ai_model_id == "custom_model_id"
|
||||
assert google_ai_chat_completion.service_id == "custom_model_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_GEMINI_MODEL_ID"]], indirect=True)
|
||||
def test_google_ai_chat_completion_init_with_empty_model_id(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAIChatCompletion with an empty model_id"""
|
||||
with pytest.raises(ServiceInitializationError, match="The Google AI Gemini model ID is required."):
|
||||
GoogleAIChatCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_API_KEY"]], indirect=True)
|
||||
def test_google_ai_chat_completion_init_with_empty_api_key(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAIChatCompletion with an empty api_key"""
|
||||
with pytest.raises(ServiceInitializationError, match="API key is required when use_vertexai is False."):
|
||||
GoogleAIChatCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_CLOUD_PROJECT_ID"]], indirect=True)
|
||||
def test_google_ai_chat_completion_init_with_use_vertexai_missing_project_id(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAIChatCompletion with use_vertexai true but missing project_id"""
|
||||
with pytest.raises(ServiceInitializationError, match="Project ID must be provided when use_vertexai is True."):
|
||||
GoogleAIChatCompletion(use_vertexai=True, env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_CLOUD_REGION"]], indirect=True)
|
||||
def test_google_ai_chat_completion_init_with_use_vertexai_missing_region(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAIChatCompletion with use_vertexai true but missing region"""
|
||||
with pytest.raises(ServiceInitializationError, match="Region must be provided when use_vertexai is True."):
|
||||
GoogleAIChatCompletion(use_vertexai=True, env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_API_KEY"]], indirect=True)
|
||||
def test_google_ai_chat_completion_init_with_use_vertexai_no_api_key(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAIChatCompletion succeeds with use_vertexai=True and no api_key"""
|
||||
chat_completion = GoogleAIChatCompletion(use_vertexai=True)
|
||||
assert chat_completion.service_settings.use_vertexai is True
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(google_ai_unit_test_env) -> None:
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
assert google_ai_chat_completion.get_prompt_execution_settings_class() == GoogleAIChatPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion init
|
||||
|
||||
|
||||
# region chat completion
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "generate_content", new_callable=AsyncMock)
|
||||
async def test_google_ai_chat_completion(
|
||||
mock_google_ai_model_generate_content,
|
||||
google_ai_unit_test_env,
|
||||
chat_history: ChatHistory,
|
||||
mock_google_ai_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test chat completion with GoogleAIChatCompletion"""
|
||||
settings = GoogleAIChatPromptExecutionSettings(top_k=5, temperature=0.7)
|
||||
|
||||
mock_google_ai_model_generate_content.return_value = mock_google_ai_chat_completion_response
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
responses: list[ChatMessageContent] = await google_ai_chat_completion.get_chat_message_contents(
|
||||
chat_history, settings
|
||||
)
|
||||
|
||||
mock_google_ai_model_generate_content.assert_called_once_with(
|
||||
model=google_ai_chat_completion.service_settings.gemini_model_id,
|
||||
contents=google_ai_chat_completion._prepare_chat_history_for_request(chat_history),
|
||||
config=GenerateContentConfigDict(
|
||||
**settings.prepare_settings_dict(),
|
||||
system_instruction=filter_system_message(chat_history),
|
||||
),
|
||||
)
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == mock_google_ai_chat_completion_response.candidates[0].content.parts[0].text
|
||||
assert responses[0].finish_reason == FinishReason.STOP
|
||||
assert "usage" in responses[0].metadata
|
||||
assert "prompt_feedback" in responses[0].metadata
|
||||
assert responses[0].inner_content == mock_google_ai_chat_completion_response
|
||||
|
||||
|
||||
async def test_google_ai_chat_completion_with_custom_client(
|
||||
chat_history: ChatHistory,
|
||||
google_ai_unit_test_env,
|
||||
mock_google_ai_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test chat completion with GoogleAIChatCompletion using a custom client"""
|
||||
# Create a custom client with a fake API key for testing
|
||||
custom_client = Client(api_key="fake-api-key-for-testing")
|
||||
|
||||
# Mock the custom client's generate_content method
|
||||
mock_generate_content = AsyncMock(return_value=mock_google_ai_chat_completion_response)
|
||||
custom_client.aio.models.generate_content = mock_generate_content
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion(client=custom_client)
|
||||
responses: list[ChatMessageContent] = await google_ai_chat_completion.get_chat_message_contents(
|
||||
chat_history,
|
||||
GoogleAIChatPromptExecutionSettings(),
|
||||
)
|
||||
|
||||
custom_client.close()
|
||||
|
||||
# Verify that the custom client was used and returned the expected response
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == mock_google_ai_chat_completion_response.candidates[0].content.parts[0].text
|
||||
assert responses[0].finish_reason == FinishReason.STOP
|
||||
|
||||
# Verify that the custom client's method was called
|
||||
mock_generate_content.assert_called_once()
|
||||
|
||||
|
||||
async def test_google_ai_chat_completion_with_function_choice_behavior_fail_verification(
|
||||
chat_history: ChatHistory,
|
||||
google_ai_unit_test_env,
|
||||
) -> None:
|
||||
"""Test completion of GoogleAIChatCompletion with function choice behavior expect verification failure"""
|
||||
|
||||
# Missing kernel
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
settings = GoogleAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
|
||||
await google_ai_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "generate_content", new_callable=AsyncMock)
|
||||
async def test_google_ai_chat_completion_with_function_choice_behavior(
|
||||
mock_google_ai_model_generate_content,
|
||||
google_ai_unit_test_env,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_google_ai_chat_completion_response_with_tool_call,
|
||||
) -> None:
|
||||
"""Test completion of GoogleAIChatCompletion with function choice behavior"""
|
||||
mock_google_ai_model_generate_content.return_value = mock_google_ai_chat_completion_response_with_tool_call
|
||||
|
||||
settings = GoogleAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
|
||||
responses = await google_ai_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
kernel=kernel,
|
||||
)
|
||||
|
||||
# The function should be called twice:
|
||||
# One for the tool call and one for the last completion
|
||||
# after the maximum_auto_invoke_attempts is reached
|
||||
assert mock_google_ai_model_generate_content.call_count == 2
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
# Google doesn't return STOP as the finish reason for tool calls
|
||||
assert responses[0].finish_reason == FinishReason.STOP
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "generate_content", new_callable=AsyncMock)
|
||||
async def test_google_ai_chat_completion_with_function_choice_behavior_no_tool_call(
|
||||
mock_google_ai_model_generate_content,
|
||||
google_ai_unit_test_env,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_google_ai_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test completion of GoogleAIChatCompletion with function choice behavior but no tool call returned"""
|
||||
mock_google_ai_model_generate_content.return_value = mock_google_ai_chat_completion_response
|
||||
|
||||
settings = GoogleAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
|
||||
responses = await google_ai_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
kernel=kernel,
|
||||
)
|
||||
|
||||
mock_google_ai_model_generate_content.assert_awaited_once_with(
|
||||
model=google_ai_chat_completion.service_settings.gemini_model_id,
|
||||
contents=google_ai_chat_completion._prepare_chat_history_for_request(chat_history),
|
||||
config=GenerateContentConfigDict(
|
||||
**settings.prepare_settings_dict(),
|
||||
system_instruction=filter_system_message(chat_history),
|
||||
),
|
||||
)
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == mock_google_ai_chat_completion_response.candidates[0].content.parts[0].text
|
||||
|
||||
|
||||
# endregion chat completion
|
||||
|
||||
|
||||
# region streaming chat completion
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "generate_content_stream", new_callable=AsyncMock)
|
||||
async def test_google_ai_streaming_chat_completion(
|
||||
mock_google_ai_model_generate_content_stream,
|
||||
google_ai_unit_test_env,
|
||||
chat_history: ChatHistory,
|
||||
mock_google_ai_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test streaming chat completion with GoogleAIChatCompletion"""
|
||||
settings = GoogleAIChatPromptExecutionSettings()
|
||||
|
||||
mock_google_ai_model_generate_content_stream.return_value = mock_google_ai_streaming_chat_completion_response
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
async for messages in google_ai_chat_completion.get_streaming_chat_message_contents(chat_history, settings):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
assert messages[0].finish_reason == FinishReason.STOP
|
||||
assert "usage" in messages[0].metadata
|
||||
assert "prompt_feedback" in messages[0].metadata
|
||||
|
||||
mock_google_ai_model_generate_content_stream.assert_called_once_with(
|
||||
model=google_ai_chat_completion.service_settings.gemini_model_id,
|
||||
contents=google_ai_chat_completion._prepare_chat_history_for_request(chat_history),
|
||||
config=GenerateContentConfigDict(
|
||||
**settings.prepare_settings_dict(),
|
||||
system_instruction=filter_system_message(chat_history),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_google_ai_streaming_chat_completion_with_custom_client(
|
||||
chat_history: ChatHistory,
|
||||
google_ai_unit_test_env,
|
||||
mock_google_ai_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test streaming chat completion with GoogleAIChatCompletion using a custom client"""
|
||||
# Create a custom client with a fake API key for testing
|
||||
custom_client = Client(api_key="fake-api-key-for-testing")
|
||||
|
||||
# Mock the custom client's generate_content_stream method
|
||||
mock_generate_content_stream = AsyncMock(return_value=mock_google_ai_streaming_chat_completion_response)
|
||||
custom_client.aio.models.generate_content_stream = mock_generate_content_stream
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion(client=custom_client)
|
||||
|
||||
all_messages = []
|
||||
async for messages in google_ai_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history, GoogleAIChatPromptExecutionSettings()
|
||||
):
|
||||
all_messages.extend(messages)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
assert messages[0].finish_reason == FinishReason.STOP
|
||||
assert "usage" in messages[0].metadata
|
||||
assert "prompt_feedback" in messages[0].metadata
|
||||
|
||||
custom_client.close()
|
||||
|
||||
# Verify that the custom client was used and returned the expected response
|
||||
assert len(all_messages) > 0
|
||||
|
||||
# Verify that the custom client's method was called
|
||||
mock_generate_content_stream.assert_called_once()
|
||||
|
||||
|
||||
async def test_google_ai_streaming_chat_completion_with_function_choice_behavior_fail_verification(
|
||||
chat_history: ChatHistory,
|
||||
google_ai_unit_test_env,
|
||||
) -> None:
|
||||
"""Test streaming chat completion of GoogleAIChatCompletion with function choice
|
||||
behavior expect verification failure"""
|
||||
|
||||
# Missing kernel
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
settings = GoogleAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
|
||||
async for _ in google_ai_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "generate_content_stream", new_callable=AsyncMock)
|
||||
async def test_google_ai_streaming_chat_completion_with_function_choice_behavior(
|
||||
mock_google_ai_model_generate_content_stream,
|
||||
google_ai_unit_test_env,
|
||||
kernel: Kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_google_ai_streaming_chat_completion_response_with_tool_call,
|
||||
decorated_native_function,
|
||||
) -> None:
|
||||
"""Test streaming chat completion of GoogleAIChatCompletion with function choice behavior"""
|
||||
mock_google_ai_model_generate_content_stream.return_value = (
|
||||
mock_google_ai_streaming_chat_completion_response_with_tool_call
|
||||
)
|
||||
|
||||
settings = GoogleAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
|
||||
kernel.add_function(plugin_name="TestPlugin", function=decorated_native_function)
|
||||
|
||||
all_messages = []
|
||||
async for messages in google_ai_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history,
|
||||
settings,
|
||||
kernel=kernel,
|
||||
):
|
||||
all_messages.extend(messages)
|
||||
|
||||
assert len(all_messages) == 2, f"Expected 2 messages, got {len(all_messages)}"
|
||||
|
||||
# Validate the first message
|
||||
assert all_messages[0].role == "assistant", f"Unexpected role for first message: {all_messages[0].role}"
|
||||
assert all_messages[0].content == "", f"Unexpected content for first message: {all_messages[0].content}"
|
||||
assert all_messages[0].finish_reason == FinishReason.STOP, (
|
||||
f"Unexpected finish reason for first message: {all_messages[0].finish_reason}"
|
||||
)
|
||||
|
||||
# Validate the second message
|
||||
assert all_messages[1].role == "tool", f"Unexpected role for second message: {all_messages[1].role}"
|
||||
assert all_messages[1].content == "", f"Unexpected content for second message: {all_messages[1].content}"
|
||||
assert all_messages[1].finish_reason is None
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "generate_content_stream", new_callable=AsyncMock)
|
||||
async def test_google_ai_streaming_chat_completion_with_function_choice_behavior_no_tool_call(
|
||||
mock_google_ai_model_generate_content_stream,
|
||||
google_ai_unit_test_env,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_google_ai_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test completion of GoogleAIChatCompletion with function choice behavior but no tool call returned"""
|
||||
mock_google_ai_model_generate_content_stream.return_value = mock_google_ai_streaming_chat_completion_response
|
||||
|
||||
settings = GoogleAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
|
||||
async for messages in google_ai_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
kernel=kernel,
|
||||
):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
assert messages[0].content == "Test content"
|
||||
|
||||
mock_google_ai_model_generate_content_stream.assert_awaited_once_with(
|
||||
model=google_ai_chat_completion.service_settings.gemini_model_id,
|
||||
contents=google_ai_chat_completion._prepare_chat_history_for_request(chat_history),
|
||||
config=GenerateContentConfigDict(
|
||||
**settings.prepare_settings_dict(),
|
||||
system_instruction=filter_system_message(chat_history),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# endregion streaming chat completion
|
||||
|
||||
|
||||
def test_google_ai_chat_completion_parse_chat_history_correctly(google_ai_unit_test_env) -> None:
|
||||
"""Test _prepare_chat_history_for_request method"""
|
||||
google_ai_chat_completion = GoogleAIChatCompletion()
|
||||
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_system_message("test_system_message")
|
||||
chat_history.add_user_message("test_user_message")
|
||||
chat_history.add_assistant_message("test_assistant_message")
|
||||
|
||||
parsed_chat_history = google_ai_chat_completion._prepare_chat_history_for_request(chat_history)
|
||||
|
||||
assert isinstance(parsed_chat_history, list)
|
||||
# System message should be ignored
|
||||
assert len(parsed_chat_history) == 2
|
||||
assert all(isinstance(message, Content) for message in parsed_chat_history)
|
||||
assert parsed_chat_history[0].role == "user"
|
||||
assert parsed_chat_history[0].parts[0].text == "test_user_message"
|
||||
assert parsed_chat_history[1].role == "model"
|
||||
assert parsed_chat_history[1].parts[0].text == "test_assistant_message"
|
||||
|
||||
|
||||
# region deserialization (Part → FunctionCallContent round-trip)
|
||||
|
||||
|
||||
def test_create_chat_message_content_with_thought_signature(google_ai_unit_test_env) -> None:
|
||||
"""Test that thought_signature from a Part is deserialized into FunctionCallContent.metadata."""
|
||||
from google.genai.types import (
|
||||
Candidate,
|
||||
Content,
|
||||
GenerateContentResponse,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
)
|
||||
from google.genai.types import (
|
||||
FinishReason as GFinishReason,
|
||||
)
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
|
||||
thought_sig_value = b"test-thought-sig-bytes"
|
||||
part = Part.from_function_call(name="test_function", args={"key": "value"})
|
||||
part.thought_signature = thought_sig_value
|
||||
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(role="user", parts=[part])
|
||||
candidate.finish_reason = GFinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates = [candidate]
|
||||
response.usage_metadata = GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=0, cached_content_token_count=0, candidates_token_count=0, total_token_count=0
|
||||
)
|
||||
|
||||
completion = GoogleAIChatCompletion()
|
||||
result = completion._create_chat_message_content(response, candidate)
|
||||
|
||||
fc_items = [item for item in result.items if isinstance(item, FunctionCallContent)]
|
||||
assert len(fc_items) == 1
|
||||
assert fc_items[0].metadata is not None
|
||||
assert fc_items[0].metadata["thought_signature"] == thought_sig_value
|
||||
|
||||
|
||||
def test_create_chat_message_content_without_thought_signature(google_ai_unit_test_env) -> None:
|
||||
"""Test that FunctionCallContent works when Part has no thought_signature."""
|
||||
from google.genai.types import (
|
||||
Candidate,
|
||||
Content,
|
||||
GenerateContentResponse,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
)
|
||||
from google.genai.types import (
|
||||
FinishReason as GFinishReason,
|
||||
)
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
|
||||
part = Part.from_function_call(name="test_function", args={"key": "value"})
|
||||
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(role="user", parts=[part])
|
||||
candidate.finish_reason = GFinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates = [candidate]
|
||||
response.usage_metadata = GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=0, cached_content_token_count=0, candidates_token_count=0, total_token_count=0
|
||||
)
|
||||
|
||||
completion = GoogleAIChatCompletion()
|
||||
result = completion._create_chat_message_content(response, candidate)
|
||||
|
||||
fc_items = [item for item in result.items if isinstance(item, FunctionCallContent)]
|
||||
assert len(fc_items) == 1
|
||||
assert "thought_signature" not in fc_items[0].metadata
|
||||
|
||||
|
||||
def test_create_streaming_chat_message_content_with_thought_signature(google_ai_unit_test_env) -> None:
|
||||
"""Test that thought_signature from a Part is deserialized in streaming path."""
|
||||
from google.genai.types import (
|
||||
Candidate,
|
||||
Content,
|
||||
GenerateContentResponse,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
)
|
||||
from google.genai.types import (
|
||||
FinishReason as GFinishReason,
|
||||
)
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
|
||||
thought_sig_value = b"streaming-thought-sig"
|
||||
part = Part.from_function_call(name="stream_func", args={"a": "b"})
|
||||
part.thought_signature = thought_sig_value
|
||||
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(role="user", parts=[part])
|
||||
candidate.finish_reason = GFinishReason.STOP
|
||||
|
||||
chunk = GenerateContentResponse()
|
||||
chunk.candidates = [candidate]
|
||||
chunk.usage_metadata = GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=0, cached_content_token_count=0, candidates_token_count=0, total_token_count=0
|
||||
)
|
||||
|
||||
completion = GoogleAIChatCompletion()
|
||||
result = completion._create_streaming_chat_message_content(chunk, candidate)
|
||||
|
||||
fc_items = [item for item in result.items if isinstance(item, FunctionCallContent)]
|
||||
assert len(fc_items) == 1
|
||||
assert fc_items[0].metadata is not None
|
||||
assert fc_items[0].metadata["thought_signature"] == thought_sig_value
|
||||
|
||||
|
||||
def test_create_streaming_chat_message_content_without_thought_signature(google_ai_unit_test_env) -> None:
|
||||
"""Test that streaming FunctionCallContent works when Part lacks thought_signature."""
|
||||
from google.genai.types import (
|
||||
Candidate,
|
||||
Content,
|
||||
GenerateContentResponse,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
)
|
||||
from google.genai.types import (
|
||||
FinishReason as GFinishReason,
|
||||
)
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
|
||||
part = Part.from_function_call(name="stream_func", args={"a": "b"})
|
||||
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(role="user", parts=[part])
|
||||
candidate.finish_reason = GFinishReason.STOP
|
||||
|
||||
chunk = GenerateContentResponse()
|
||||
chunk.candidates = [candidate]
|
||||
chunk.usage_metadata = GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=0, cached_content_token_count=0, candidates_token_count=0, total_token_count=0
|
||||
)
|
||||
|
||||
completion = GoogleAIChatCompletion()
|
||||
result = completion._create_streaming_chat_message_content(chunk, candidate)
|
||||
|
||||
fc_items = [item for item in result.items if isinstance(item, FunctionCallContent)]
|
||||
assert len(fc_items) == 1
|
||||
assert "thought_signature" not in fc_items[0].metadata
|
||||
|
||||
|
||||
def test_create_chat_message_content_getattr_guard_on_missing_attribute(google_ai_unit_test_env) -> None:
|
||||
"""Test that getattr guard handles SDK versions where thought_signature doesn't exist on Part."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.genai.types import (
|
||||
GenerateContentResponse,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
)
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
|
||||
# Create a mock Part that lacks 'thought_signature' attribute entirely
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = None
|
||||
mock_part.function_call.name = "test_func"
|
||||
mock_part.function_call.args = {"x": "y"}
|
||||
del mock_part.thought_signature # simulate older SDK without the field
|
||||
|
||||
# Use a fully-mocked candidate to avoid Content pydantic validation
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.index = 0
|
||||
mock_candidate.content.parts = [mock_part]
|
||||
mock_candidate.finish_reason = 1 # STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates = [mock_candidate]
|
||||
response.usage_metadata = GenerateContentResponseUsageMetadata(
|
||||
prompt_token_count=0, cached_content_token_count=0, candidates_token_count=0, total_token_count=0
|
||||
)
|
||||
|
||||
completion = GoogleAIChatCompletion()
|
||||
result = completion._create_chat_message_content(response, mock_candidate)
|
||||
|
||||
fc_items = [item for item in result.items if isinstance(item, FunctionCallContent)]
|
||||
assert len(fc_items) == 1
|
||||
assert "thought_signature" not in fc_items[0].metadata
|
||||
|
||||
|
||||
# endregion deserialization
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from google.genai import Client
|
||||
from google.genai.models import AsyncModels
|
||||
from google.genai.types import GenerateContentConfigDict
|
||||
|
||||
from semantic_kernel.connectors.ai.google.google_ai.google_ai_prompt_execution_settings import (
|
||||
GoogleAITextPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.google.google_ai.google_ai_settings import GoogleAISettings
|
||||
from semantic_kernel.connectors.ai.google.google_ai.services.google_ai_text_completion import GoogleAITextCompletion
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
# region init
|
||||
def test_google_ai_text_completion_init(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextCompletion"""
|
||||
model_id = google_ai_unit_test_env["GOOGLE_AI_GEMINI_MODEL_ID"]
|
||||
api_key = google_ai_unit_test_env["GOOGLE_AI_API_KEY"]
|
||||
google_ai_text_completion = GoogleAITextCompletion()
|
||||
|
||||
assert google_ai_text_completion.ai_model_id == model_id
|
||||
assert google_ai_text_completion.service_id == model_id
|
||||
|
||||
assert isinstance(google_ai_text_completion.service_settings, GoogleAISettings)
|
||||
assert google_ai_text_completion.service_settings.gemini_model_id == model_id
|
||||
assert google_ai_text_completion.service_settings.api_key.get_secret_value() == api_key
|
||||
|
||||
|
||||
def test_google_ai_text_completion_init_with_service_id(google_ai_unit_test_env, service_id) -> None:
|
||||
"""Test initialization of GoogleAITextCompletion with a service_id that is not the model_id"""
|
||||
google_ai_text_completion = GoogleAITextCompletion(service_id=service_id)
|
||||
|
||||
assert google_ai_text_completion.service_id == service_id
|
||||
|
||||
|
||||
def test_google_ai_text_completion_init_with_model_id_in_argument(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAIChatCompletion with model_id in argument"""
|
||||
google_ai_text_completion = GoogleAITextCompletion(gemini_model_id="custom_model_id")
|
||||
|
||||
assert google_ai_text_completion.ai_model_id == "custom_model_id"
|
||||
assert google_ai_text_completion.service_id == "custom_model_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_GEMINI_MODEL_ID"]], indirect=True)
|
||||
def test_google_ai_text_completion_init_with_empty_model_id(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextCompletion with an empty model_id"""
|
||||
with pytest.raises(ServiceInitializationError, match="The Google AI Gemini model ID is required."):
|
||||
GoogleAITextCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_API_KEY"]], indirect=True)
|
||||
def test_google_ai_text_completion_init_with_empty_api_key(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextCompletion with an empty api_key"""
|
||||
with pytest.raises(ServiceInitializationError, match="The API key is required when use_vertexai is False."):
|
||||
GoogleAITextCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", ["GOOGLE_AI_CLOUD_PROJECT_ID"], indirect=True)
|
||||
def test_google_ai_text_completion_init_with_use_vertexai_missing_project_id(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextCompletion with use_vertexai true but missing project_id"""
|
||||
with pytest.raises(ServiceInitializationError, match="Project ID must be provided when use_vertexai is True."):
|
||||
GoogleAITextCompletion(use_vertexai=True, env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_CLOUD_REGION"]], indirect=True)
|
||||
def test_google_ai_text_completion_init_with_use_vertexai_missing_region(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextCompletion with use_vertexai true but missing region"""
|
||||
with pytest.raises(ServiceInitializationError, match="Region must be provided when use_vertexai is True."):
|
||||
GoogleAITextCompletion(use_vertexai=True, env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_API_KEY"]], indirect=True)
|
||||
def test_google_ai_text_completion_init_with_use_vertexai_no_api_key(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextCompletion succeeds with use_vertexai=True and no api_key"""
|
||||
text_completion = GoogleAITextCompletion(use_vertexai=True)
|
||||
assert text_completion.service_settings.use_vertexai is True
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(google_ai_unit_test_env) -> None:
|
||||
google_ai_text_completion = GoogleAITextCompletion()
|
||||
assert google_ai_text_completion.get_prompt_execution_settings_class() == GoogleAITextPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion init
|
||||
|
||||
|
||||
# region text completion
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "generate_content", new_callable=AsyncMock)
|
||||
async def test_google_ai_text_completion(
|
||||
mock_google_model_generate_content,
|
||||
google_ai_unit_test_env,
|
||||
prompt: str,
|
||||
mock_google_ai_text_completion_response,
|
||||
) -> None:
|
||||
"""Test text completion with GoogleAITextCompletion"""
|
||||
settings = GoogleAITextPromptExecutionSettings()
|
||||
|
||||
mock_google_model_generate_content.return_value = mock_google_ai_text_completion_response
|
||||
|
||||
google_ai_text_completion = GoogleAITextCompletion()
|
||||
responses: list[TextContent] = await google_ai_text_completion.get_text_contents(prompt, settings)
|
||||
|
||||
mock_google_model_generate_content.assert_called_once_with(
|
||||
model=google_ai_text_completion.service_settings.gemini_model_id,
|
||||
contents=prompt,
|
||||
config=GenerateContentConfigDict(**settings.prepare_settings_dict()),
|
||||
)
|
||||
assert len(responses) == 1
|
||||
assert responses[0].text == mock_google_ai_text_completion_response.candidates[0].content.parts[0].text
|
||||
assert "usage" in responses[0].metadata
|
||||
assert "prompt_feedback" in responses[0].metadata
|
||||
assert responses[0].inner_content == mock_google_ai_text_completion_response
|
||||
|
||||
|
||||
async def test_google_ai_text_completion_with_custom_client(
|
||||
prompt: str,
|
||||
google_ai_unit_test_env,
|
||||
mock_google_ai_text_completion_response,
|
||||
) -> None:
|
||||
"""Test text completion with GoogleAITextCompletion using a custom client"""
|
||||
# Create a custom client with a fake API key for testing
|
||||
custom_client = Client(api_key="fake-api-key-for-testing")
|
||||
|
||||
# Mock the custom client's generate_content method
|
||||
mock_generate_content = AsyncMock(return_value=mock_google_ai_text_completion_response)
|
||||
custom_client.aio.models.generate_content = mock_generate_content
|
||||
|
||||
google_ai_text_completion = GoogleAITextCompletion(client=custom_client)
|
||||
responses: list[TextContent] = await google_ai_text_completion.get_text_contents(
|
||||
prompt,
|
||||
GoogleAITextPromptExecutionSettings(),
|
||||
)
|
||||
|
||||
custom_client.close()
|
||||
|
||||
# Verify that the custom client was used and returned the expected response
|
||||
assert len(responses) == 1
|
||||
assert responses[0].text == mock_google_ai_text_completion_response.candidates[0].content.parts[0].text
|
||||
assert "usage" in responses[0].metadata
|
||||
assert "prompt_feedback" in responses[0].metadata
|
||||
assert responses[0].inner_content == mock_google_ai_text_completion_response
|
||||
|
||||
# Verify that the custom client's method was called
|
||||
mock_generate_content.assert_called_once()
|
||||
|
||||
|
||||
# endregion text completion
|
||||
|
||||
|
||||
# region streaming text completion
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "generate_content_stream", new_callable=AsyncMock)
|
||||
async def test_google_ai_streaming_text_completion(
|
||||
mock_google_model_generate_content_stream,
|
||||
google_ai_unit_test_env,
|
||||
prompt: str,
|
||||
mock_google_ai_streaming_text_completion_response,
|
||||
) -> None:
|
||||
"""Test streaming text completion with GoogleAITextCompletion"""
|
||||
settings = GoogleAITextPromptExecutionSettings()
|
||||
|
||||
mock_google_model_generate_content_stream.return_value = mock_google_ai_streaming_text_completion_response
|
||||
|
||||
google_ai_text_completion = GoogleAITextCompletion()
|
||||
async for chunks in google_ai_text_completion.get_streaming_text_contents(prompt, settings):
|
||||
assert len(chunks) == 1
|
||||
assert "usage" in chunks[0].metadata
|
||||
assert "prompt_feedback" in chunks[0].metadata
|
||||
|
||||
mock_google_model_generate_content_stream.assert_called_once_with(
|
||||
model=google_ai_text_completion.service_settings.gemini_model_id,
|
||||
contents=prompt,
|
||||
config=GenerateContentConfigDict(**settings.prepare_settings_dict()),
|
||||
)
|
||||
|
||||
|
||||
async def test_google_ai_streaming_text_completion_with_custom_client(
|
||||
prompt: str,
|
||||
google_ai_unit_test_env,
|
||||
mock_google_ai_streaming_text_completion_response,
|
||||
) -> None:
|
||||
"""Test streaming text completion with GoogleAITextCompletion using a custom client"""
|
||||
# Create a custom client with a fake API key for testing
|
||||
custom_client = Client(api_key="fake-api-key-for-testing")
|
||||
|
||||
# Mock the custom client's generate_content_stream method
|
||||
mock_generate_content_stream = AsyncMock(return_value=mock_google_ai_streaming_text_completion_response)
|
||||
custom_client.aio.models.generate_content_stream = mock_generate_content_stream
|
||||
|
||||
google_ai_text_completion = GoogleAITextCompletion(client=custom_client)
|
||||
async for chunks in google_ai_text_completion.get_streaming_text_contents(
|
||||
prompt, GoogleAITextPromptExecutionSettings()
|
||||
):
|
||||
assert len(chunks) == 1
|
||||
assert "usage" in chunks[0].metadata
|
||||
assert "prompt_feedback" in chunks[0].metadata
|
||||
|
||||
custom_client.close()
|
||||
|
||||
# Verify that the custom client's method was called
|
||||
mock_generate_content_stream.assert_called_once()
|
||||
|
||||
|
||||
# endregion streaming text completion
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from google.genai import Client
|
||||
from google.genai.models import AsyncModels
|
||||
from google.genai.types import ContentEmbedding, EmbedContentConfigDict, EmbedContentResponse
|
||||
from numpy import array, ndarray
|
||||
|
||||
from semantic_kernel.connectors.ai.google.google_ai.google_ai_prompt_execution_settings import (
|
||||
GoogleAIEmbeddingPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.google.google_ai.google_ai_settings import GoogleAISettings
|
||||
from semantic_kernel.connectors.ai.google.google_ai.services.google_ai_text_embedding import GoogleAITextEmbedding
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
# region init
|
||||
def test_google_ai_text_embedding_init(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextEmbedding"""
|
||||
model_id = google_ai_unit_test_env["GOOGLE_AI_EMBEDDING_MODEL_ID"]
|
||||
api_key = google_ai_unit_test_env["GOOGLE_AI_API_KEY"]
|
||||
google_ai_text_embedding = GoogleAITextEmbedding()
|
||||
|
||||
assert google_ai_text_embedding.ai_model_id == model_id
|
||||
assert google_ai_text_embedding.service_id == model_id
|
||||
|
||||
assert isinstance(google_ai_text_embedding.service_settings, GoogleAISettings)
|
||||
assert google_ai_text_embedding.service_settings.embedding_model_id == model_id
|
||||
assert google_ai_text_embedding.service_settings.api_key.get_secret_value() == api_key
|
||||
|
||||
|
||||
def test_google_ai_text_embedding_init_with_service_id(google_ai_unit_test_env, service_id) -> None:
|
||||
"""Test initialization of GoogleAITextEmbedding with a service_id that is not the model_id"""
|
||||
google_ai_text_embedding = GoogleAITextEmbedding(service_id=service_id)
|
||||
|
||||
assert google_ai_text_embedding.service_id == service_id
|
||||
|
||||
|
||||
def test_google_ai_text_embedding_init_with_model_id_in_argument(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextEmbedding with model_id in argument"""
|
||||
google_ai_chat_completion = GoogleAITextEmbedding(embedding_model_id="custom_model_id")
|
||||
|
||||
assert google_ai_chat_completion.ai_model_id == "custom_model_id"
|
||||
assert google_ai_chat_completion.service_id == "custom_model_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_google_ai_text_embedding_init_with_empty_model_id(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextEmbedding with an empty model_id"""
|
||||
with pytest.raises(ServiceInitializationError, match="The Google AI embedding model ID is required."):
|
||||
GoogleAITextEmbedding(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_API_KEY"]], indirect=True)
|
||||
def test_google_ai_text_embedding_init_with_empty_api_key(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextEmbedding with an empty api_key"""
|
||||
with pytest.raises(ServiceInitializationError, match="The API key is required when use_vertexai is False."):
|
||||
GoogleAITextEmbedding(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_API_KEY"]], indirect=True)
|
||||
def test_google_ai_text_embedding_init_with_use_vertexai_no_api_key(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextEmbedding succeeds with use_vertexai=True and no api_key"""
|
||||
embedding = GoogleAITextEmbedding(use_vertexai=True)
|
||||
assert embedding.service_settings.use_vertexai is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_CLOUD_PROJECT_ID"]], indirect=True)
|
||||
def test_google_ai_text_embedding_init_with_use_vertexai_missing_project_id(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextEmbedding with use_vertexai true but missing project ID"""
|
||||
with pytest.raises(ServiceInitializationError, match="Project ID must be provided when use_vertexai is True."):
|
||||
GoogleAITextEmbedding(use_vertexai=True, env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["GOOGLE_AI_CLOUD_REGION"]], indirect=True)
|
||||
def test_google_ai_text_embedding_init_with_use_vertexai_missing_region(google_ai_unit_test_env) -> None:
|
||||
"""Test initialization of GoogleAITextEmbedding with use_vertexai true but missing region"""
|
||||
with pytest.raises(ServiceInitializationError, match="Region must be provided when use_vertexai is True."):
|
||||
GoogleAITextEmbedding(use_vertexai=True, env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(google_ai_unit_test_env) -> None:
|
||||
google_ai_text_embedding = GoogleAITextEmbedding()
|
||||
assert google_ai_text_embedding.get_prompt_execution_settings_class() == GoogleAIEmbeddingPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion init
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "embed_content", new_callable=AsyncMock)
|
||||
async def test_embedding(mock_google_model_embed_content, google_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly."""
|
||||
model_id = google_ai_unit_test_env["GOOGLE_AI_EMBEDDING_MODEL_ID"]
|
||||
|
||||
mock_google_model_embed_content.return_value = EmbedContentResponse(
|
||||
embeddings=[ContentEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
)
|
||||
settings = GoogleAIEmbeddingPromptExecutionSettings()
|
||||
|
||||
google_ai_text_embedding = GoogleAITextEmbedding()
|
||||
response: ndarray = await google_ai_text_embedding.generate_embeddings(
|
||||
[prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert len(response) == 1
|
||||
assert response.all() == array([0.1, 0.2, 0.3]).all()
|
||||
mock_google_model_embed_content.assert_called_once_with(
|
||||
model=model_id,
|
||||
contents=[prompt],
|
||||
config=EmbedContentConfigDict(output_dimensionality=settings.output_dimensionality),
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "embed_content", new_callable=AsyncMock)
|
||||
async def test_embedding_with_settings(mock_google_model_embed_content, google_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly."""
|
||||
model_id = google_ai_unit_test_env["GOOGLE_AI_EMBEDDING_MODEL_ID"]
|
||||
|
||||
mock_google_model_embed_content.return_value = EmbedContentResponse(
|
||||
embeddings=[ContentEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
)
|
||||
settings = GoogleAIEmbeddingPromptExecutionSettings()
|
||||
settings.output_dimensionality = 3
|
||||
|
||||
google_ai_text_embedding = GoogleAITextEmbedding()
|
||||
response: ndarray = await google_ai_text_embedding.generate_embeddings(
|
||||
[prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert len(response) == 1
|
||||
assert response.all() == array([0.1, 0.2, 0.3]).all()
|
||||
mock_google_model_embed_content.assert_called_once_with(
|
||||
model=model_id,
|
||||
contents=[prompt],
|
||||
config=EmbedContentConfigDict(**settings.prepare_settings_dict()),
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "embed_content", new_callable=AsyncMock)
|
||||
async def test_embedding_without_settings(mock_google_model_embed_content, google_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly without settings."""
|
||||
model_id = google_ai_unit_test_env["GOOGLE_AI_EMBEDDING_MODEL_ID"]
|
||||
|
||||
mock_google_model_embed_content.return_value = EmbedContentResponse(
|
||||
embeddings=[ContentEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
)
|
||||
google_ai_text_embedding = GoogleAITextEmbedding()
|
||||
response: ndarray = await google_ai_text_embedding.generate_embeddings([prompt])
|
||||
|
||||
assert len(response) == 1
|
||||
assert response.all() == array([0.1, 0.2, 0.3]).all()
|
||||
mock_google_model_embed_content.assert_called_once_with(
|
||||
model=model_id,
|
||||
contents=[prompt],
|
||||
config=EmbedContentConfigDict(output_dimensionality=None),
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "embed_content", new_callable=AsyncMock)
|
||||
async def test_embedding_list_input(mock_google_model_embed_content, google_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly with a list of prompts."""
|
||||
model_id = google_ai_unit_test_env["GOOGLE_AI_EMBEDDING_MODEL_ID"]
|
||||
|
||||
mock_google_model_embed_content.return_value = EmbedContentResponse(
|
||||
embeddings=[ContentEmbedding(values=[0.1, 0.2, 0.3]), ContentEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
)
|
||||
settings = GoogleAIEmbeddingPromptExecutionSettings()
|
||||
|
||||
google_ai_text_embedding = GoogleAITextEmbedding()
|
||||
response: ndarray = await google_ai_text_embedding.generate_embeddings(
|
||||
[prompt, prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert len(response) == 2
|
||||
assert response.all() == array([[0.1, 0.2, 0.3], [0.1, 0.2, 0.3]]).all()
|
||||
mock_google_model_embed_content.assert_called_once_with(
|
||||
model=model_id,
|
||||
contents=[prompt, prompt],
|
||||
config=EmbedContentConfigDict(output_dimensionality=settings.output_dimensionality),
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncModels, "embed_content", new_callable=AsyncMock)
|
||||
async def test_raw_embedding(mock_google_model_embed_content, google_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly."""
|
||||
model_id = google_ai_unit_test_env["GOOGLE_AI_EMBEDDING_MODEL_ID"]
|
||||
|
||||
mock_google_model_embed_content.return_value = EmbedContentResponse(
|
||||
embeddings=[ContentEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
)
|
||||
settings = GoogleAIEmbeddingPromptExecutionSettings()
|
||||
|
||||
google_ai_text_embedding = GoogleAITextEmbedding()
|
||||
response: list[list[float]] = await google_ai_text_embedding.generate_raw_embeddings(
|
||||
[prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert len(response) == 1
|
||||
assert response[0] == [0.1, 0.2, 0.3]
|
||||
mock_google_model_embed_content.assert_called_once_with(
|
||||
model=model_id,
|
||||
contents=[prompt],
|
||||
config=EmbedContentConfigDict(output_dimensionality=settings.output_dimensionality),
|
||||
)
|
||||
|
||||
|
||||
async def test_embedding_with_custom_client(google_ai_unit_test_env, prompt) -> None:
|
||||
"""Test embedding with GoogleAITextEmbedding using a custom client"""
|
||||
# Create a custom client with a fake API key for testing
|
||||
custom_client = Client(api_key="fake-api-key-for-testing")
|
||||
|
||||
# Mock the custom client's embed_content method
|
||||
mock_embed_content = AsyncMock(
|
||||
return_value=EmbedContentResponse(embeddings=[ContentEmbedding(values=[0.1, 0.2, 0.3])])
|
||||
)
|
||||
custom_client.aio.models.embed_content = mock_embed_content
|
||||
|
||||
google_ai_text_embedding = GoogleAITextEmbedding(client=custom_client)
|
||||
response: list[list[float]] = await google_ai_text_embedding.generate_embeddings(
|
||||
[prompt],
|
||||
GoogleAIEmbeddingPromptExecutionSettings(),
|
||||
)
|
||||
|
||||
custom_client.close()
|
||||
|
||||
# Verify that the custom client was used and returned the expected response
|
||||
assert len(response) == 1
|
||||
assert response.all() == array([0.1, 0.2, 0.3]).all()
|
||||
|
||||
# Verify that the custom client's method was called
|
||||
mock_embed_content.assert_called_once()
|
||||
@@ -0,0 +1,201 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from google.genai.types import FinishReason, Part
|
||||
|
||||
from semantic_kernel.connectors.ai.google.google_ai.services.utils import (
|
||||
finish_reason_from_google_ai_to_semantic_kernel,
|
||||
format_assistant_message,
|
||||
format_user_message,
|
||||
kernel_function_metadata_to_google_ai_function_call_format,
|
||||
)
|
||||
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.image_content import ImageContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.contents.utils.finish_reason import FinishReason as SemanticKernelFinishReason
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInvalidRequestError
|
||||
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
|
||||
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
|
||||
|
||||
|
||||
def test_finish_reason_from_google_ai_to_semantic_kernel():
|
||||
"""Test finish_reason_from_google_ai_to_semantic_kernel."""
|
||||
assert finish_reason_from_google_ai_to_semantic_kernel(FinishReason.STOP) == SemanticKernelFinishReason.STOP
|
||||
assert finish_reason_from_google_ai_to_semantic_kernel(FinishReason.MAX_TOKENS) == SemanticKernelFinishReason.LENGTH
|
||||
assert (
|
||||
finish_reason_from_google_ai_to_semantic_kernel(FinishReason.SAFETY)
|
||||
== SemanticKernelFinishReason.CONTENT_FILTER
|
||||
)
|
||||
assert finish_reason_from_google_ai_to_semantic_kernel(FinishReason.OTHER) is None
|
||||
assert finish_reason_from_google_ai_to_semantic_kernel(None) is None
|
||||
|
||||
|
||||
def test_format_user_message():
|
||||
"""Test format_user_message."""
|
||||
user_message = ChatMessageContent(role=AuthorRole.USER, content="User message")
|
||||
formatted_user_message = format_user_message(user_message)
|
||||
|
||||
assert len(formatted_user_message) == 1
|
||||
assert isinstance(formatted_user_message[0], Part)
|
||||
assert formatted_user_message[0].text == "User message"
|
||||
|
||||
# Test with an image content
|
||||
image_content = ImageContent(data="image data", mime_type="image/png")
|
||||
user_message = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[
|
||||
TextContent(text="Text content"),
|
||||
image_content,
|
||||
],
|
||||
)
|
||||
formatted_user_message = format_user_message(user_message)
|
||||
|
||||
assert len(formatted_user_message) == 2
|
||||
assert isinstance(formatted_user_message[0], Part)
|
||||
assert formatted_user_message[0].text == "Text content"
|
||||
assert isinstance(formatted_user_message[1], Part)
|
||||
assert formatted_user_message[1].inline_data.mime_type == "image/png"
|
||||
assert formatted_user_message[1].inline_data.data == image_content.data
|
||||
|
||||
|
||||
def test_format_user_message_throws_with_unsupported_items() -> None:
|
||||
"""Test format_user_message with unsupported items."""
|
||||
# Test with unsupported items, any item other than TextContent and ImageContent should raise an error
|
||||
user_message = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[
|
||||
FunctionCallContent(),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
format_user_message(user_message)
|
||||
|
||||
# Test with an ImageContent that has no data_uri
|
||||
user_message = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[
|
||||
ImageContent(data_uri=""),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
format_user_message(user_message)
|
||||
|
||||
|
||||
def test_format_assistant_message() -> None:
|
||||
assistant_message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
TextContent(text="test"),
|
||||
FunctionCallContent(name="test_function", arguments={}),
|
||||
ImageContent(data="image data", mime_type="image/png"),
|
||||
],
|
||||
)
|
||||
|
||||
formatted_assistant_message = format_assistant_message(assistant_message)
|
||||
assert isinstance(formatted_assistant_message, list)
|
||||
assert len(formatted_assistant_message) == 3
|
||||
assert isinstance(formatted_assistant_message[0], Part)
|
||||
assert formatted_assistant_message[0].text == "test"
|
||||
assert isinstance(formatted_assistant_message[1], Part)
|
||||
assert formatted_assistant_message[1].function_call.name == "test_function"
|
||||
assert formatted_assistant_message[1].function_call.args == {}
|
||||
assert isinstance(formatted_assistant_message[2], Part)
|
||||
assert formatted_assistant_message[2].inline_data
|
||||
|
||||
|
||||
def test_format_assistant_message_with_unsupported_items() -> None:
|
||||
assistant_message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
FunctionResultContent(id="test_id", function_name="test_function"),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
format_assistant_message(assistant_message)
|
||||
|
||||
|
||||
def test_format_assistant_message_with_thought_signature() -> None:
|
||||
"""Test that thought_signature is preserved in function call parts."""
|
||||
import base64
|
||||
|
||||
thought_sig = base64.b64encode(b"test_thought_signature_data")
|
||||
assistant_message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
FunctionCallContent(
|
||||
name="test_function",
|
||||
arguments={"arg1": "value1"},
|
||||
metadata={"thought_signature": thought_sig},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
formatted = format_assistant_message(assistant_message)
|
||||
assert len(formatted) == 1
|
||||
assert isinstance(formatted[0], Part)
|
||||
assert formatted[0].function_call.name == "test_function"
|
||||
assert formatted[0].function_call.args == {"arg1": "value1"}
|
||||
assert formatted[0].thought_signature == thought_sig
|
||||
|
||||
|
||||
def test_format_assistant_message_without_thought_signature() -> None:
|
||||
"""Test that function calls without thought_signature still work."""
|
||||
assistant_message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
FunctionCallContent(
|
||||
name="test_function",
|
||||
arguments={"arg1": "value1"},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
formatted = format_assistant_message(assistant_message)
|
||||
assert len(formatted) == 1
|
||||
assert isinstance(formatted[0], Part)
|
||||
assert formatted[0].function_call.name == "test_function"
|
||||
assert formatted[0].function_call.args == {"arg1": "value1"}
|
||||
assert not getattr(formatted[0], "thought_signature", None)
|
||||
|
||||
|
||||
def test_google_ai_function_call_format_sanitizes_anyof_schema() -> None:
|
||||
"""Integration test: anyOf in param schema_data is sanitized in the output dict."""
|
||||
metadata = KernelFunctionMetadata(
|
||||
name="test_func",
|
||||
description="A test function",
|
||||
is_prompt=False,
|
||||
parameters=[
|
||||
KernelParameterMetadata(
|
||||
name="messages",
|
||||
description="The user messages",
|
||||
is_required=True,
|
||||
schema_data={
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
],
|
||||
"description": "The user messages",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
result = kernel_function_metadata_to_google_ai_function_call_format(metadata)
|
||||
param_schema = result["parameters"]["properties"]["messages"]
|
||||
assert "anyOf" not in param_schema
|
||||
assert param_schema["type"] == "string"
|
||||
|
||||
|
||||
def test_google_ai_function_call_format_empty_parameters() -> None:
|
||||
"""Integration test: metadata with no parameters produces parameters=None."""
|
||||
metadata = KernelFunctionMetadata(
|
||||
name="no_params_func",
|
||||
description="No parameters",
|
||||
is_prompt=False,
|
||||
parameters=[],
|
||||
)
|
||||
result = kernel_function_metadata_to_google_ai_function_call_format(metadata)
|
||||
assert result["parameters"] is None
|
||||
@@ -0,0 +1,133 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.ai.google.google_ai import (
|
||||
GoogleAIChatPromptExecutionSettings,
|
||||
GoogleAIEmbeddingPromptExecutionSettings,
|
||||
GoogleAIPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
|
||||
|
||||
def test_default_google_ai_prompt_execution_settings():
|
||||
settings = GoogleAIPromptExecutionSettings()
|
||||
|
||||
assert settings.stop_sequences is None
|
||||
assert settings.response_mime_type is None
|
||||
assert settings.response_schema is None
|
||||
assert settings.candidate_count is None
|
||||
assert settings.max_output_tokens is None
|
||||
assert settings.temperature is None
|
||||
assert settings.top_p is None
|
||||
assert settings.top_k is None
|
||||
|
||||
|
||||
def test_custom_google_ai_prompt_execution_settings():
|
||||
settings = GoogleAIPromptExecutionSettings(
|
||||
stop_sequences=["world"],
|
||||
response_mime_type="text/plain",
|
||||
candidate_count=1,
|
||||
max_output_tokens=128,
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
top_k=10,
|
||||
)
|
||||
|
||||
assert settings.stop_sequences == ["world"]
|
||||
assert settings.response_mime_type == "text/plain"
|
||||
assert settings.candidate_count == 1
|
||||
assert settings.max_output_tokens == 128
|
||||
assert settings.temperature == 0.5
|
||||
assert settings.top_p == 0.5
|
||||
assert settings.top_k == 10
|
||||
|
||||
|
||||
def test_google_ai_prompt_execution_settings_from_default_completion_config():
|
||||
settings = PromptExecutionSettings(service_id="test_service")
|
||||
chat_settings = GoogleAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.stop_sequences is None
|
||||
assert chat_settings.response_mime_type is None
|
||||
assert chat_settings.response_schema is None
|
||||
assert chat_settings.candidate_count is None
|
||||
assert chat_settings.max_output_tokens is None
|
||||
assert chat_settings.temperature is None
|
||||
assert chat_settings.top_p is None
|
||||
assert chat_settings.top_k is None
|
||||
|
||||
|
||||
def test_google_ai_prompt_execution_settings_from_openai_prompt_execution_settings():
|
||||
chat_settings = GoogleAIChatPromptExecutionSettings(service_id="test_service", temperature=1.0)
|
||||
new_settings = GoogleAIPromptExecutionSettings(service_id="test_2", temperature=0.0)
|
||||
chat_settings.update_from_prompt_execution_settings(new_settings)
|
||||
|
||||
assert chat_settings.service_id == "test_2"
|
||||
assert chat_settings.temperature == 0.0
|
||||
|
||||
|
||||
def test_google_ai_prompt_execution_settings_from_custom_completion_config():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"stop_sequences": ["world"],
|
||||
"response_mime_type": "text/plain",
|
||||
"candidate_count": 1,
|
||||
"max_output_tokens": 128,
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"top_k": 10,
|
||||
},
|
||||
)
|
||||
chat_settings = GoogleAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.stop_sequences == ["world"]
|
||||
assert chat_settings.response_mime_type == "text/plain"
|
||||
assert chat_settings.candidate_count == 1
|
||||
assert chat_settings.max_output_tokens == 128
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.top_k == 10
|
||||
|
||||
|
||||
def test_google_ai_chat_prompt_execution_settings_from_custom_completion_config_with_functions():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"tools": [{"function": {}}],
|
||||
},
|
||||
)
|
||||
chat_settings = GoogleAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.tools == [{"function": {}}]
|
||||
|
||||
|
||||
def test_create_options():
|
||||
settings = GoogleAIChatPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"stop_sequences": ["world"],
|
||||
"response_mime_type": "text/plain",
|
||||
"candidate_count": 1,
|
||||
"max_output_tokens": 128,
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"top_k": 10,
|
||||
},
|
||||
)
|
||||
options = settings.prepare_settings_dict()
|
||||
|
||||
assert options["stop_sequences"] == ["world"]
|
||||
assert options["response_mime_type"] == "text/plain"
|
||||
assert options["candidate_count"] == 1
|
||||
assert options["max_output_tokens"] == 128
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.5
|
||||
assert options["top_k"] == 10
|
||||
assert "tools" not in options
|
||||
assert "tool_config" not in options
|
||||
|
||||
|
||||
def test_default_google_ai_embedding_prompt_execution_settings():
|
||||
settings = GoogleAIEmbeddingPromptExecutionSettings()
|
||||
|
||||
assert settings.output_dimensionality is None
|
||||
@@ -0,0 +1,268 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceType
|
||||
from semantic_kernel.connectors.ai.google.shared_utils import (
|
||||
FUNCTION_CHOICE_TYPE_TO_GOOGLE_FUNCTION_CALLING_MODE,
|
||||
GEMINI_FUNCTION_NAME_SEPARATOR,
|
||||
collapse_function_call_results_in_chat_history,
|
||||
filter_system_message,
|
||||
format_gemini_function_name_to_kernel_function_fully_qualified_name,
|
||||
sanitize_schema_for_google_ai,
|
||||
)
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
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.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInvalidRequestError
|
||||
|
||||
|
||||
def test_first_system_message():
|
||||
"""Test filter_system_message."""
|
||||
# Test with a single system message
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_system_message("System message")
|
||||
chat_history.add_user_message("User message")
|
||||
assert filter_system_message(chat_history) == "System message"
|
||||
|
||||
# Test with no system message
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("User message")
|
||||
assert filter_system_message(chat_history) is None
|
||||
|
||||
# Test with multiple system messages
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_system_message("System message 1")
|
||||
chat_history.add_system_message("System message 2")
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
filter_system_message(chat_history)
|
||||
|
||||
|
||||
def test_function_choice_type_to_google_function_calling_mode_contain_all_types() -> None:
|
||||
assert FunctionChoiceType.AUTO in FUNCTION_CHOICE_TYPE_TO_GOOGLE_FUNCTION_CALLING_MODE
|
||||
assert FunctionChoiceType.NONE in FUNCTION_CHOICE_TYPE_TO_GOOGLE_FUNCTION_CALLING_MODE
|
||||
assert FunctionChoiceType.REQUIRED in FUNCTION_CHOICE_TYPE_TO_GOOGLE_FUNCTION_CALLING_MODE
|
||||
|
||||
|
||||
def test_format_gemini_function_name_to_kernel_function_fully_qualified_name() -> None:
|
||||
# Contains the separator
|
||||
gemini_function_name = f"plugin{GEMINI_FUNCTION_NAME_SEPARATOR}function"
|
||||
assert (
|
||||
format_gemini_function_name_to_kernel_function_fully_qualified_name(gemini_function_name) == "plugin-function"
|
||||
)
|
||||
|
||||
# Doesn't contain the separator
|
||||
gemini_function_name = "function"
|
||||
assert format_gemini_function_name_to_kernel_function_fully_qualified_name(gemini_function_name) == "function"
|
||||
|
||||
|
||||
def test_collapse_function_call_results_in_chat_history() -> None:
|
||||
chat_history = ChatHistory()
|
||||
chat_history.extend([
|
||||
ChatMessageContent(
|
||||
AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
FunctionCallContent(id="function1", name="function1"),
|
||||
FunctionCallContent(id="function2", name="function2"),
|
||||
],
|
||||
),
|
||||
# The following two messages should be collapsed into a single message
|
||||
ChatMessageContent(
|
||||
AuthorRole.TOOL,
|
||||
items=[FunctionResultContent(id="function1", name="function1", result="result1")],
|
||||
),
|
||||
ChatMessageContent(
|
||||
AuthorRole.TOOL,
|
||||
items=[FunctionResultContent(id="function2", name="function2", result="result2")],
|
||||
),
|
||||
ChatMessageContent(AuthorRole.ASSISTANT, content="Assistant message"),
|
||||
ChatMessageContent(AuthorRole.USER, content="User message"),
|
||||
ChatMessageContent(
|
||||
AuthorRole.ASSISTANT,
|
||||
items=[FunctionCallContent(id="function3", name="function3")],
|
||||
),
|
||||
ChatMessageContent(
|
||||
AuthorRole.TOOL,
|
||||
items=[FunctionResultContent(id="function3", name="function3", result="result3")],
|
||||
),
|
||||
ChatMessageContent(AuthorRole.ASSISTANT, content="Assistant message"),
|
||||
])
|
||||
|
||||
assert len(chat_history.messages) == 8
|
||||
collapse_function_call_results_in_chat_history(chat_history)
|
||||
assert len(chat_history.messages) == 7
|
||||
assert len(chat_history.messages[1].items) == 2
|
||||
|
||||
|
||||
# --- sanitize_schema_for_google_ai tests ---
|
||||
|
||||
|
||||
def test_sanitize_schema_none():
|
||||
"""Test that None input returns None."""
|
||||
assert sanitize_schema_for_google_ai(None) is None
|
||||
|
||||
|
||||
def test_sanitize_schema_simple_passthrough():
|
||||
"""Test that a simple schema passes through unchanged."""
|
||||
schema = {"type": "string", "description": "A name"}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {"type": "string", "description": "A name"}
|
||||
|
||||
|
||||
def test_sanitize_schema_type_as_list_with_null():
|
||||
"""type: ["string", "null"] should become type: "string" + nullable: true."""
|
||||
schema = {"type": ["string", "null"], "description": "Optional field"}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {"type": "string", "nullable": True, "description": "Optional field"}
|
||||
|
||||
|
||||
def test_sanitize_schema_type_as_list_without_null():
|
||||
"""type: ["string", "integer"] should pick the first type."""
|
||||
schema = {"type": ["string", "integer"]}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {"type": "string"}
|
||||
|
||||
|
||||
def test_sanitize_schema_anyof_with_null():
|
||||
"""AnyOf with null variant should become the non-null type + nullable."""
|
||||
schema = {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Optional param",
|
||||
}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {"type": "string", "nullable": True, "description": "Optional param"}
|
||||
|
||||
|
||||
def test_sanitize_schema_anyof_without_null():
|
||||
"""AnyOf without null should pick the first variant."""
|
||||
schema = {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
],
|
||||
}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {"type": "string"}
|
||||
|
||||
|
||||
def test_sanitize_schema_oneof():
|
||||
"""OneOf should be handled the same as anyOf."""
|
||||
schema = {
|
||||
"oneOf": [{"type": "integer"}, {"type": "null"}],
|
||||
}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {"type": "integer", "nullable": True}
|
||||
|
||||
|
||||
def test_sanitize_schema_nested_properties():
|
||||
"""AnyOf inside nested properties should be sanitized recursively."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"value": {"anyOf": [{"type": "number"}, {"type": "null"}]},
|
||||
},
|
||||
}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"value": {"type": "number", "nullable": True},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_sanitize_schema_nested_items():
|
||||
"""AnyOf inside array items should be sanitized recursively."""
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
|
||||
}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
}
|
||||
|
||||
|
||||
def test_sanitize_schema_does_not_mutate_original():
|
||||
"""The original schema dict should not be modified."""
|
||||
schema = {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "test",
|
||||
}
|
||||
original = {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "test"}
|
||||
sanitize_schema_for_google_ai(schema)
|
||||
assert schema == original
|
||||
|
||||
|
||||
def test_sanitize_schema_agent_messages_param():
|
||||
"""Reproducer for issue #12442: str | list[str] parameter schema."""
|
||||
schema = {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
],
|
||||
"description": "The user messages for the agent.",
|
||||
}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert "anyOf" not in result
|
||||
assert result["type"] == "string"
|
||||
assert result["description"] == "The user messages for the agent."
|
||||
|
||||
|
||||
def test_sanitize_schema_allof():
|
||||
"""AllOf should be handled like anyOf/oneOf, picking the first variant."""
|
||||
schema = {
|
||||
"allOf": [
|
||||
{"type": "object", "properties": {"name": {"type": "string"}}},
|
||||
{"type": "object", "properties": {"age": {"type": "integer"}}},
|
||||
],
|
||||
}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert "allOf" not in result
|
||||
assert result["type"] == "object"
|
||||
assert "name" in result["properties"]
|
||||
|
||||
|
||||
def test_sanitize_schema_allof_with_null():
|
||||
"""AllOf with a null variant should produce nullable: true."""
|
||||
schema = {
|
||||
"allOf": [{"type": "string"}, {"type": "null"}],
|
||||
}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert "allOf" not in result
|
||||
assert result["type"] == "string"
|
||||
assert result["nullable"] is True
|
||||
|
||||
|
||||
def test_sanitize_schema_all_null_type_list():
|
||||
"""type: ["null"] should fall back to type: "string" + nullable: true."""
|
||||
schema = {"type": ["null"]}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {"type": "string", "nullable": True}
|
||||
|
||||
|
||||
def test_sanitize_schema_all_null_anyof():
|
||||
"""AnyOf where all variants are null should fall back to type: "string"."""
|
||||
schema = {"anyOf": [{"type": "null"}]}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result == {"type": "string", "nullable": True}
|
||||
|
||||
|
||||
def test_sanitize_schema_chosen_variant_keeps_own_description():
|
||||
"""When the chosen anyOf variant has its own description, do not overwrite it."""
|
||||
schema = {
|
||||
"anyOf": [
|
||||
{"type": "string", "description": "inner desc"},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "outer desc",
|
||||
}
|
||||
result = sanitize_schema_for_google_ai(schema)
|
||||
assert result["description"] == "inner desc"
|
||||
assert result["nullable"] is True
|
||||
@@ -0,0 +1,189 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncGenerator, AsyncIterable
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from google.cloud.aiplatform_v1beta1.types.content import Candidate, Content, Part
|
||||
from google.cloud.aiplatform_v1beta1.types.prediction_service import GenerateContentResponse
|
||||
from google.cloud.aiplatform_v1beta1.types.tool import FunctionCall
|
||||
from vertexai.generative_models import GenerationResponse
|
||||
from vertexai.language_models import TextEmbedding
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def vertex_ai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Vertex AI Unit Tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"VERTEX_AI_GEMINI_MODEL_ID": "test-gemini-model-id",
|
||||
"VERTEX_AI_EMBEDDING_MODEL_ID": "test-embedding-model-id",
|
||||
"VERTEX_AI_PROJECT_ID": "test-project-id",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_vertex_ai_chat_completion_response() -> GenerationResponse:
|
||||
"""Mock Vertex AI Chat Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(role="user", parts=[Part(text="Test content")])
|
||||
candidate.finish_reason = Candidate.FinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates.append(candidate)
|
||||
response.usage_metadata = GenerateContentResponse.UsageMetadata(
|
||||
prompt_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
return GenerationResponse._from_gapic(response)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_vertex_ai_chat_completion_response_with_tool_call() -> GenerationResponse:
|
||||
"""Mock Vertex AI Chat Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(
|
||||
role="user",
|
||||
parts=[
|
||||
Part(
|
||||
function_call=FunctionCall(
|
||||
name="test_function",
|
||||
args={"test_arg": "test_value"},
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
candidate.finish_reason = Candidate.FinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates.append(candidate)
|
||||
response.usage_metadata = GenerateContentResponse.UsageMetadata(
|
||||
prompt_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
return GenerationResponse._from_gapic(response)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_vertex_ai_streaming_chat_completion_response() -> AsyncIterable[GenerationResponse]:
|
||||
"""Mock Vertex AI streaming Chat Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(role="user", parts=[Part(text="Test content")])
|
||||
candidate.finish_reason = Candidate.FinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates.append(candidate)
|
||||
response.usage_metadata = GenerateContentResponse.UsageMetadata(
|
||||
prompt_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
iterable = MagicMock(spec=AsyncGenerator)
|
||||
iterable.__aiter__.return_value = [GenerationResponse._from_gapic(response)]
|
||||
|
||||
return iterable
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_vertex_ai_streaming_chat_completion_response_with_tool_call() -> AsyncIterable[GenerationResponse]:
|
||||
"""Mock Vertex AI streaming Chat Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(
|
||||
role="user",
|
||||
parts=[
|
||||
Part(
|
||||
function_call=FunctionCall(
|
||||
name="getLightStatus",
|
||||
args={"arg1": "test_value"},
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
candidate.finish_reason = Candidate.FinishReason.STOP
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates.append(candidate)
|
||||
response.usage_metadata = GenerateContentResponse.UsageMetadata(
|
||||
prompt_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
iterable = MagicMock(spec=AsyncGenerator)
|
||||
iterable.__aiter__.return_value = [GenerationResponse._from_gapic(response)]
|
||||
|
||||
return iterable
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_vertex_ai_text_completion_response() -> GenerationResponse:
|
||||
"""Mock Vertex AI Text Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(parts=[Part(text="Test content")])
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates.append(candidate)
|
||||
response.usage_metadata = GenerateContentResponse.UsageMetadata(
|
||||
prompt_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
return GenerationResponse._from_gapic(response)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_vertex_ai_streaming_text_completion_response() -> AsyncIterable[GenerationResponse]:
|
||||
"""Mock Vertex AI streaming Text Completion response."""
|
||||
candidate = Candidate()
|
||||
candidate.index = 0
|
||||
candidate.content = Content(parts=[Part(text="Test content")])
|
||||
|
||||
response = GenerateContentResponse()
|
||||
response.candidates.append(candidate)
|
||||
response.usage_metadata = GenerateContentResponse.UsageMetadata(
|
||||
prompt_token_count=0,
|
||||
candidates_token_count=0,
|
||||
total_token_count=0,
|
||||
)
|
||||
|
||||
iterable = MagicMock(spec=AsyncGenerator)
|
||||
iterable.__aiter__.return_value = [GenerationResponse._from_gapic(response)]
|
||||
|
||||
return iterable
|
||||
|
||||
|
||||
class MockTextEmbeddingModel:
|
||||
async def get_embeddings_async(
|
||||
self,
|
||||
texts: list[str],
|
||||
*,
|
||||
auto_truncate: bool = True,
|
||||
output_dimensionality: int | None = None,
|
||||
) -> list[TextEmbedding]:
|
||||
pass
|
||||
+545
@@ -0,0 +1,545 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from vertexai.generative_models import Content, GenerativeModel
|
||||
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.services.vertex_ai_chat_completion import VertexAIChatCompletion
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_prompt_execution_settings import (
|
||||
VertexAIChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_settings import VertexAISettings
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.finish_reason import FinishReason
|
||||
from semantic_kernel.exceptions.service_exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
)
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
# region init
|
||||
def test_vertex_ai_chat_completion_init(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAIChatCompletion"""
|
||||
model_id = vertex_ai_unit_test_env["VERTEX_AI_GEMINI_MODEL_ID"]
|
||||
project_id = vertex_ai_unit_test_env["VERTEX_AI_PROJECT_ID"]
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
|
||||
assert vertex_ai_chat_completion.ai_model_id == model_id
|
||||
assert vertex_ai_chat_completion.service_id == model_id
|
||||
|
||||
assert isinstance(vertex_ai_chat_completion.service_settings, VertexAISettings)
|
||||
assert vertex_ai_chat_completion.service_settings.gemini_model_id == model_id
|
||||
assert vertex_ai_chat_completion.service_settings.project_id == project_id
|
||||
|
||||
|
||||
def test_vertex_ai_chat_completion_init_with_service_id(vertex_ai_unit_test_env, service_id) -> None:
|
||||
"""Test initialization of VertexAIChatCompletion with a service id that is not the model id"""
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion(service_id=service_id)
|
||||
|
||||
assert vertex_ai_chat_completion.service_id == service_id
|
||||
|
||||
|
||||
def test_vertex_ai_chat_completion_init_with_model_id_in_argument(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAIChatCompletion with model id in argument"""
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion(gemini_model_id="custom_model_id")
|
||||
|
||||
assert vertex_ai_chat_completion.ai_model_id == "custom_model_id"
|
||||
assert vertex_ai_chat_completion.service_id == "custom_model_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["VERTEX_AI_GEMINI_MODEL_ID"]], indirect=True)
|
||||
def test_vertex_ai_chat_completion_init_with_empty_model_id(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAIChatCompletion with an empty model id"""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
VertexAIChatCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["VERTEX_AI_PROJECT_ID"]], indirect=True)
|
||||
def test_vertex_ai_chat_completion_init_with_empty_project_id(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAIChatCompletion with an empty project id"""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
VertexAIChatCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(vertex_ai_unit_test_env) -> None:
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
assert vertex_ai_chat_completion.get_prompt_execution_settings_class() == VertexAIChatPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion init
|
||||
|
||||
|
||||
# region chat completion
|
||||
|
||||
|
||||
@patch.object(GenerativeModel, "generate_content_async", new_callable=AsyncMock)
|
||||
async def test_vertex_ai_chat_completion(
|
||||
mock_vertex_ai_model_generate_content_async,
|
||||
vertex_ai_unit_test_env,
|
||||
chat_history: ChatHistory,
|
||||
mock_vertex_ai_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test chat completion with VertexAIChatCompletion"""
|
||||
settings = VertexAIChatPromptExecutionSettings()
|
||||
|
||||
mock_vertex_ai_model_generate_content_async.return_value = mock_vertex_ai_chat_completion_response
|
||||
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
responses: list[ChatMessageContent] = await vertex_ai_chat_completion.get_chat_message_contents(
|
||||
chat_history, settings
|
||||
)
|
||||
|
||||
# Verify the call was made once
|
||||
mock_vertex_ai_model_generate_content_async.assert_called_once()
|
||||
|
||||
# Get the actual call arguments
|
||||
call_args = mock_vertex_ai_model_generate_content_async.call_args
|
||||
|
||||
# Verify the contents
|
||||
contents = call_args.kwargs["contents"]
|
||||
assert len(contents) == 1
|
||||
assert contents[0].role == "user"
|
||||
assert len(contents[0].parts) == 1
|
||||
assert contents[0].parts[0].text == "test_prompt"
|
||||
|
||||
# Verify other arguments
|
||||
assert call_args.kwargs["generation_config"] == settings.prepare_settings_dict()
|
||||
assert call_args.kwargs["tools"] is None
|
||||
assert call_args.kwargs["tool_config"] is None
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == mock_vertex_ai_chat_completion_response.candidates[0].content.parts[0].text
|
||||
assert responses[0].finish_reason == FinishReason.STOP
|
||||
assert "usage" in responses[0].metadata
|
||||
assert "prompt_feedback" in responses[0].metadata
|
||||
assert responses[0].inner_content == mock_vertex_ai_chat_completion_response
|
||||
|
||||
|
||||
async def test_vertex_ai_chat_completion_with_function_choice_behavior_fail_verification(
|
||||
chat_history: ChatHistory,
|
||||
vertex_ai_unit_test_env,
|
||||
) -> None:
|
||||
"""Test completion of VertexAIChatCompletion with function choice behavior expect verification failure"""
|
||||
|
||||
# Missing kernel
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
settings = VertexAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
|
||||
await vertex_ai_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(GenerativeModel, "generate_content_async", new_callable=AsyncMock)
|
||||
async def test_vertex_ai_chat_completion_with_function_choice_behavior(
|
||||
mock_vertex_ai_model_generate_content_async,
|
||||
vertex_ai_unit_test_env,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_vertex_ai_chat_completion_response_with_tool_call,
|
||||
) -> None:
|
||||
"""Test completion of VertexAIChatCompletion with function choice behavior"""
|
||||
mock_vertex_ai_model_generate_content_async.return_value = mock_vertex_ai_chat_completion_response_with_tool_call
|
||||
|
||||
settings = VertexAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
|
||||
responses = await vertex_ai_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
kernel=kernel,
|
||||
)
|
||||
|
||||
# The function should be called twice:
|
||||
# One for the tool call and one for the last completion
|
||||
# after the maximum_auto_invoke_attempts is reached
|
||||
assert mock_vertex_ai_model_generate_content_async.call_count == 2
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
# Google doesn't return STOP as the finish reason for tool calls
|
||||
assert responses[0].finish_reason == FinishReason.STOP
|
||||
|
||||
|
||||
@patch.object(GenerativeModel, "generate_content_async", new_callable=AsyncMock)
|
||||
async def test_vertex_ai_chat_completion_with_function_choice_behavior_no_tool_call(
|
||||
mock_vertex_ai_model_generate_content_async,
|
||||
vertex_ai_unit_test_env,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_vertex_ai_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test completion of VertexAIChatCompletion with function choice behavior but no tool call returned"""
|
||||
mock_vertex_ai_model_generate_content_async.return_value = mock_vertex_ai_chat_completion_response
|
||||
|
||||
settings = VertexAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
|
||||
responses = await vertex_ai_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
kernel=kernel,
|
||||
)
|
||||
|
||||
# Verify the call was made once
|
||||
mock_vertex_ai_model_generate_content_async.assert_awaited_once()
|
||||
|
||||
# Get the actual call arguments
|
||||
call_args = mock_vertex_ai_model_generate_content_async.await_args
|
||||
|
||||
# Verify the contents
|
||||
contents = call_args.kwargs["contents"]
|
||||
assert len(contents) == 1
|
||||
assert contents[0].role == "user"
|
||||
assert len(contents[0].parts) == 1
|
||||
assert contents[0].parts[0].text == "test_prompt"
|
||||
|
||||
# Verify other arguments
|
||||
assert call_args.kwargs["generation_config"] == settings.prepare_settings_dict()
|
||||
assert call_args.kwargs["tools"] is None
|
||||
assert call_args.kwargs["tool_config"] is None
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].content == mock_vertex_ai_chat_completion_response.candidates[0].content.parts[0].text
|
||||
|
||||
|
||||
# endregion chat completion
|
||||
|
||||
|
||||
# region streaming chat completion
|
||||
|
||||
|
||||
@patch.object(GenerativeModel, "generate_content_async", new_callable=AsyncMock)
|
||||
async def test_vertex_ai_streaming_chat_completion(
|
||||
mock_vertex_ai_model_generate_content_async,
|
||||
vertex_ai_unit_test_env,
|
||||
chat_history: ChatHistory,
|
||||
mock_vertex_ai_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test chat completion with VertexAIChatCompletion"""
|
||||
settings = VertexAIChatPromptExecutionSettings()
|
||||
|
||||
mock_vertex_ai_model_generate_content_async.return_value = mock_vertex_ai_streaming_chat_completion_response
|
||||
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
async for messages in vertex_ai_chat_completion.get_streaming_chat_message_contents(chat_history, settings):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
assert messages[0].finish_reason == FinishReason.STOP
|
||||
assert "usage" in messages[0].metadata
|
||||
assert "prompt_feedback" in messages[0].metadata
|
||||
|
||||
# Verify the call was made once
|
||||
mock_vertex_ai_model_generate_content_async.assert_called_once()
|
||||
|
||||
# Get the actual call arguments
|
||||
call_args = mock_vertex_ai_model_generate_content_async.call_args
|
||||
|
||||
# Verify the contents
|
||||
contents = call_args.kwargs["contents"]
|
||||
assert len(contents) == 1
|
||||
assert contents[0].role == "user"
|
||||
assert len(contents[0].parts) == 1
|
||||
assert contents[0].parts[0].text == "test_prompt"
|
||||
|
||||
# Verify other arguments
|
||||
assert call_args.kwargs["generation_config"] == settings.prepare_settings_dict()
|
||||
assert call_args.kwargs["tools"] is None
|
||||
assert call_args.kwargs["tool_config"] is None
|
||||
assert call_args.kwargs["stream"] is True
|
||||
|
||||
|
||||
async def test_vertex_ai_streaming_chat_completion_with_function_choice_behavior_fail_verification(
|
||||
chat_history: ChatHistory,
|
||||
vertex_ai_unit_test_env,
|
||||
) -> None:
|
||||
"""Test streaming chat completion of VertexAIChatCompletion with function choice
|
||||
behavior expect verification failure"""
|
||||
|
||||
# Missing kernel
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
settings = VertexAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
|
||||
async for _ in vertex_ai_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@patch.object(GenerativeModel, "generate_content_async", new_callable=AsyncMock)
|
||||
async def test_vertex_ai_streaming_chat_completion_with_function_choice_behavior(
|
||||
mock_vertex_ai_model_generate_content_async,
|
||||
vertex_ai_unit_test_env,
|
||||
kernel: Kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_vertex_ai_streaming_chat_completion_response_with_tool_call,
|
||||
decorated_native_function,
|
||||
) -> None:
|
||||
"""Test streaming chat completion of VertexAIChatCompletion with function choice behavior"""
|
||||
mock_vertex_ai_model_generate_content_async.return_value = (
|
||||
mock_vertex_ai_streaming_chat_completion_response_with_tool_call
|
||||
)
|
||||
|
||||
settings = VertexAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
|
||||
kernel.add_function(plugin_name="TestPlugin", function=decorated_native_function)
|
||||
|
||||
all_messages = []
|
||||
async for messages in vertex_ai_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history,
|
||||
settings,
|
||||
kernel=kernel,
|
||||
):
|
||||
all_messages.extend(messages)
|
||||
|
||||
assert len(all_messages) == 2, f"Expected 2 messages, got {len(all_messages)}"
|
||||
|
||||
# Validate the first message
|
||||
assert all_messages[0].role == "assistant", f"Unexpected role for first message: {all_messages[0].role}"
|
||||
assert all_messages[0].content == "", f"Unexpected content for first message: {all_messages[0].content}"
|
||||
assert all_messages[0].finish_reason == FinishReason.STOP, (
|
||||
f"Unexpected finish reason for first message: {all_messages[0].finish_reason}"
|
||||
)
|
||||
|
||||
# Validate the second message
|
||||
assert all_messages[1].role == "tool", f"Unexpected role for second message: {all_messages[1].role}"
|
||||
assert all_messages[1].content == "", f"Unexpected content for second message: {all_messages[1].content}"
|
||||
assert all_messages[1].finish_reason is None
|
||||
|
||||
|
||||
@patch.object(GenerativeModel, "generate_content_async", new_callable=AsyncMock)
|
||||
async def test_vertex_ai_streaming_chat_completion_with_function_choice_behavior_no_tool_call(
|
||||
mock_vertex_ai_model_generate_content_async,
|
||||
vertex_ai_unit_test_env,
|
||||
kernel,
|
||||
chat_history: ChatHistory,
|
||||
mock_vertex_ai_streaming_chat_completion_response,
|
||||
) -> None:
|
||||
"""Test completion of VertexAIChatCompletion with function choice behavior but no tool call returned"""
|
||||
mock_vertex_ai_model_generate_content_async.return_value = mock_vertex_ai_streaming_chat_completion_response
|
||||
|
||||
settings = VertexAIChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
settings.function_choice_behavior.maximum_auto_invoke_attempts = 1
|
||||
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
|
||||
async for messages in vertex_ai_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=settings,
|
||||
kernel=kernel,
|
||||
):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
|
||||
# Verify the call was made once
|
||||
mock_vertex_ai_model_generate_content_async.assert_awaited_once()
|
||||
|
||||
# Get the actual call arguments
|
||||
call_args = mock_vertex_ai_model_generate_content_async.await_args
|
||||
|
||||
# Verify the contents
|
||||
contents = call_args.kwargs["contents"]
|
||||
assert len(contents) == 1
|
||||
assert contents[0].role == "user"
|
||||
assert len(contents[0].parts) == 1
|
||||
assert contents[0].parts[0].text == "test_prompt"
|
||||
|
||||
# Verify other arguments
|
||||
assert call_args.kwargs["generation_config"] == settings.prepare_settings_dict()
|
||||
assert call_args.kwargs["tools"] is None
|
||||
assert call_args.kwargs["tool_config"] is None
|
||||
assert call_args.kwargs["stream"] is True
|
||||
|
||||
|
||||
# endregion streaming chat completion
|
||||
|
||||
|
||||
def test_vertex_ai_chat_completion_parse_chat_history_correctly(vertex_ai_unit_test_env) -> None:
|
||||
"""Test _prepare_chat_history_for_request method"""
|
||||
vertex_ai_chat_completion = VertexAIChatCompletion()
|
||||
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_system_message("test_system_message")
|
||||
chat_history.add_user_message("test_user_message")
|
||||
chat_history.add_assistant_message("test_assistant_message")
|
||||
|
||||
parsed_chat_history = vertex_ai_chat_completion._prepare_chat_history_for_request(chat_history)
|
||||
|
||||
assert isinstance(parsed_chat_history, list)
|
||||
# System message should be ignored
|
||||
assert len(parsed_chat_history) == 2
|
||||
assert all(isinstance(message, Content) for message in parsed_chat_history)
|
||||
assert parsed_chat_history[0].role == "user"
|
||||
assert parsed_chat_history[0].parts[0].text == "test_user_message"
|
||||
assert parsed_chat_history[1].role == "model"
|
||||
assert parsed_chat_history[1].parts[0].text == "test_assistant_message"
|
||||
|
||||
|
||||
# region thought_signature deserialization tests
|
||||
|
||||
|
||||
def test_create_chat_message_content_with_thought_signature(vertex_ai_unit_test_env) -> None:
|
||||
"""Test that thought_signature from a Part dict is deserialized into FunctionCallContent.metadata."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.cloud.aiplatform_v1beta1.types.content import Candidate as GapicCandidate
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
|
||||
thought_sig_value = "vertex-test-thought-sig"
|
||||
|
||||
# Create a mock Part whose to_dict() returns thought_signature
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = None
|
||||
mock_part.to_dict.return_value = {
|
||||
"function_call": {"name": "test_function", "args": {"key": "value"}},
|
||||
"thought_signature": thought_sig_value,
|
||||
}
|
||||
mock_part.function_call.name = "test_function"
|
||||
mock_part.function_call.args = {"key": "value"}
|
||||
|
||||
# Build a mock candidate with the mock part
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.index = 0
|
||||
mock_candidate.content.parts = [mock_part]
|
||||
mock_candidate.finish_reason = GapicCandidate.FinishReason.STOP
|
||||
|
||||
# Build a mock response
|
||||
mock_response = MagicMock()
|
||||
|
||||
completion = VertexAIChatCompletion()
|
||||
result = completion._create_chat_message_content(mock_response, mock_candidate)
|
||||
|
||||
fc_items = [item for item in result.items if isinstance(item, FunctionCallContent)]
|
||||
assert len(fc_items) == 1
|
||||
assert fc_items[0].metadata is not None
|
||||
assert fc_items[0].metadata["thought_signature"] == thought_sig_value
|
||||
|
||||
|
||||
def test_create_chat_message_content_without_thought_signature(vertex_ai_unit_test_env) -> None:
|
||||
"""Test that FunctionCallContent works when Part dict has no thought_signature."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.cloud.aiplatform_v1beta1.types.content import Candidate as GapicCandidate
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = None
|
||||
mock_part.to_dict.return_value = {
|
||||
"function_call": {"name": "test_function", "args": {"key": "value"}},
|
||||
}
|
||||
mock_part.function_call.name = "test_function"
|
||||
mock_part.function_call.args = {"key": "value"}
|
||||
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.index = 0
|
||||
mock_candidate.content.parts = [mock_part]
|
||||
mock_candidate.finish_reason = GapicCandidate.FinishReason.STOP
|
||||
|
||||
mock_response = MagicMock()
|
||||
|
||||
completion = VertexAIChatCompletion()
|
||||
result = completion._create_chat_message_content(mock_response, mock_candidate)
|
||||
|
||||
fc_items = [item for item in result.items if isinstance(item, FunctionCallContent)]
|
||||
assert len(fc_items) == 1
|
||||
assert "thought_signature" not in fc_items[0].metadata
|
||||
|
||||
|
||||
def test_create_streaming_chat_message_content_with_thought_signature(vertex_ai_unit_test_env) -> None:
|
||||
"""Test that thought_signature from a Part dict is deserialized in streaming path."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.cloud.aiplatform_v1beta1.types.content import Candidate as GapicCandidate
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
|
||||
thought_sig_value = "vertex-streaming-thought-sig"
|
||||
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = None
|
||||
mock_part.to_dict.return_value = {
|
||||
"function_call": {"name": "stream_func", "args": {"a": "b"}},
|
||||
"thought_signature": thought_sig_value,
|
||||
}
|
||||
mock_part.function_call.name = "stream_func"
|
||||
mock_part.function_call.args = {"a": "b"}
|
||||
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.index = 0
|
||||
mock_candidate.content.parts = [mock_part]
|
||||
mock_candidate.finish_reason = GapicCandidate.FinishReason.STOP
|
||||
|
||||
mock_chunk = MagicMock()
|
||||
|
||||
completion = VertexAIChatCompletion()
|
||||
result = completion._create_streaming_chat_message_content(mock_chunk, mock_candidate, function_invoke_attempt=0)
|
||||
|
||||
fc_items = [item for item in result.items if isinstance(item, FunctionCallContent)]
|
||||
assert len(fc_items) == 1
|
||||
assert fc_items[0].metadata is not None
|
||||
assert fc_items[0].metadata["thought_signature"] == thought_sig_value
|
||||
|
||||
|
||||
def test_create_streaming_chat_message_content_without_thought_signature(vertex_ai_unit_test_env) -> None:
|
||||
"""Test that streaming FunctionCallContent works when Part dict lacks thought_signature."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.cloud.aiplatform_v1beta1.types.content import Candidate as GapicCandidate
|
||||
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = None
|
||||
mock_part.to_dict.return_value = {
|
||||
"function_call": {"name": "stream_func", "args": {"a": "b"}},
|
||||
}
|
||||
mock_part.function_call.name = "stream_func"
|
||||
mock_part.function_call.args = {"a": "b"}
|
||||
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.index = 0
|
||||
mock_candidate.content.parts = [mock_part]
|
||||
mock_candidate.finish_reason = GapicCandidate.FinishReason.STOP
|
||||
|
||||
mock_chunk = MagicMock()
|
||||
|
||||
completion = VertexAIChatCompletion()
|
||||
result = completion._create_streaming_chat_message_content(mock_chunk, mock_candidate, function_invoke_attempt=0)
|
||||
|
||||
fc_items = [item for item in result.items if isinstance(item, FunctionCallContent)]
|
||||
assert len(fc_items) == 1
|
||||
assert "thought_signature" not in fc_items[0].metadata
|
||||
|
||||
|
||||
# endregion thought_signature deserialization tests
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from vertexai.generative_models import GenerativeModel
|
||||
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.services.vertex_ai_text_completion import VertexAITextCompletion
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_prompt_execution_settings import (
|
||||
VertexAITextPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_settings import VertexAISettings
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
# region init
|
||||
def test_vertex_ai_text_completion_init(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAITextCompletion"""
|
||||
model_id = vertex_ai_unit_test_env["VERTEX_AI_GEMINI_MODEL_ID"]
|
||||
project_id = vertex_ai_unit_test_env["VERTEX_AI_PROJECT_ID"]
|
||||
vertex_ai_text_completion = VertexAITextCompletion()
|
||||
|
||||
assert vertex_ai_text_completion.ai_model_id == model_id
|
||||
assert vertex_ai_text_completion.service_id == model_id
|
||||
|
||||
assert isinstance(vertex_ai_text_completion.service_settings, VertexAISettings)
|
||||
assert vertex_ai_text_completion.service_settings.gemini_model_id == model_id
|
||||
assert vertex_ai_text_completion.service_settings.project_id == project_id
|
||||
|
||||
|
||||
def test_vertex_ai_text_completion_init_with_service_id(vertex_ai_unit_test_env, service_id) -> None:
|
||||
"""Test initialization of VertexAITextCompletion with a service id that is not the model id"""
|
||||
vertex_ai_text_completion = VertexAITextCompletion(service_id=service_id)
|
||||
|
||||
assert vertex_ai_text_completion.service_id == service_id
|
||||
|
||||
|
||||
def test_vertex_ai_text_completion_init_with_model_id_in_argument(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAIChatCompletion with model id in argument"""
|
||||
vertex_ai_text_completion = VertexAITextCompletion(gemini_model_id="custom_model_id")
|
||||
|
||||
assert vertex_ai_text_completion.ai_model_id == "custom_model_id"
|
||||
assert vertex_ai_text_completion.service_id == "custom_model_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["VERTEX_AI_GEMINI_MODEL_ID"]], indirect=True)
|
||||
def test_vertex_ai_text_completion_init_with_empty_model_id(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAITextCompletion with an empty model id"""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
VertexAITextCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["VERTEX_AI_PROJECT_ID"]], indirect=True)
|
||||
def test_vertex_ai_text_completion_init_with_empty_project_id(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAITextCompletion with an empty project id"""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
VertexAITextCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(vertex_ai_unit_test_env) -> None:
|
||||
vertex_ai_text_completion = VertexAITextCompletion()
|
||||
assert vertex_ai_text_completion.get_prompt_execution_settings_class() == VertexAITextPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion init
|
||||
|
||||
|
||||
# region text completion
|
||||
|
||||
|
||||
@patch.object(GenerativeModel, "generate_content_async", new_callable=AsyncMock)
|
||||
async def test_vertex_ai_text_completion(
|
||||
mock_vertex_ai_model_generate_content_async,
|
||||
vertex_ai_unit_test_env,
|
||||
prompt: str,
|
||||
mock_vertex_ai_text_completion_response,
|
||||
) -> None:
|
||||
"""Test text completion with VertexAITextCompletion"""
|
||||
settings = VertexAITextPromptExecutionSettings()
|
||||
|
||||
mock_vertex_ai_model_generate_content_async.return_value = mock_vertex_ai_text_completion_response
|
||||
|
||||
vertex_ai_text_completion = VertexAITextCompletion()
|
||||
responses: list[TextContent] = await vertex_ai_text_completion.get_text_contents(prompt, settings)
|
||||
|
||||
mock_vertex_ai_model_generate_content_async.assert_called_once_with(
|
||||
contents=prompt,
|
||||
generation_config=settings.prepare_settings_dict(),
|
||||
)
|
||||
assert len(responses) == 1
|
||||
assert responses[0].text == mock_vertex_ai_text_completion_response.candidates[0].content.parts[0].text
|
||||
assert "usage" in responses[0].metadata
|
||||
assert "prompt_feedback" in responses[0].metadata
|
||||
assert responses[0].inner_content == mock_vertex_ai_text_completion_response
|
||||
|
||||
|
||||
# endregion text completion
|
||||
|
||||
|
||||
# region streaming text completion
|
||||
|
||||
|
||||
@patch.object(GenerativeModel, "generate_content_async", new_callable=AsyncMock)
|
||||
async def test_vertex_ai_streaming_text_completion(
|
||||
mock_vertex_ai_model_generate_content_async,
|
||||
vertex_ai_unit_test_env,
|
||||
prompt: str,
|
||||
mock_vertex_ai_streaming_text_completion_response,
|
||||
) -> None:
|
||||
"""Test streaming text completion with VertexAITextCompletion"""
|
||||
settings = VertexAITextPromptExecutionSettings()
|
||||
|
||||
mock_vertex_ai_model_generate_content_async.return_value = mock_vertex_ai_streaming_text_completion_response
|
||||
|
||||
vertex_ai_text_completion = VertexAITextCompletion()
|
||||
async for chunks in vertex_ai_text_completion.get_streaming_text_contents(prompt, settings):
|
||||
assert len(chunks) == 1
|
||||
assert "usage" in chunks[0].metadata
|
||||
assert "prompt_feedback" in chunks[0].metadata
|
||||
|
||||
mock_vertex_ai_model_generate_content_async.assert_called_once_with(
|
||||
contents=prompt,
|
||||
generation_config=settings.prepare_settings_dict(),
|
||||
stream=True,
|
||||
)
|
||||
|
||||
|
||||
# endregion streaming text completion
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from numpy import array, ndarray
|
||||
from vertexai.language_models import TextEmbedding, TextEmbeddingModel
|
||||
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.services.vertex_ai_text_embedding import VertexAITextEmbedding
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_prompt_execution_settings import (
|
||||
VertexAIEmbeddingPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_settings import VertexAISettings
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
from tests.unit.connectors.ai.google.vertex_ai.conftest import MockTextEmbeddingModel
|
||||
|
||||
|
||||
# region init
|
||||
def test_vertex_ai_text_embedding_init(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAITextEmbedding"""
|
||||
model_id = vertex_ai_unit_test_env["VERTEX_AI_EMBEDDING_MODEL_ID"]
|
||||
project_id = vertex_ai_unit_test_env["VERTEX_AI_PROJECT_ID"]
|
||||
vertex_ai_text_embedding = VertexAITextEmbedding()
|
||||
|
||||
assert vertex_ai_text_embedding.ai_model_id == model_id
|
||||
assert vertex_ai_text_embedding.service_id == model_id
|
||||
|
||||
assert isinstance(vertex_ai_text_embedding.service_settings, VertexAISettings)
|
||||
assert vertex_ai_text_embedding.service_settings.embedding_model_id == model_id
|
||||
assert vertex_ai_text_embedding.service_settings.project_id == project_id
|
||||
|
||||
|
||||
def test_vertex_ai_text_embedding_init_with_service_id(vertex_ai_unit_test_env, service_id) -> None:
|
||||
"""Test initialization of VertexAITextEmbedding with a service id that is not the model id"""
|
||||
vertex_ai_text_embedding = VertexAITextEmbedding(service_id=service_id)
|
||||
|
||||
assert vertex_ai_text_embedding.service_id == service_id
|
||||
|
||||
|
||||
def test_vertex_ai_text_embedding_init_with_model_id_in_argument(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAITextEmbedding with model id in argument"""
|
||||
vertex_ai_chat_completion = VertexAITextEmbedding(embedding_model_id="custom_model_id")
|
||||
|
||||
assert vertex_ai_chat_completion.ai_model_id == "custom_model_id"
|
||||
assert vertex_ai_chat_completion.service_id == "custom_model_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["VERTEX_AI_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_vertex_ai_text_embedding_init_with_empty_model_id(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAITextEmbedding with an empty model id"""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
VertexAITextEmbedding(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["VERTEX_AI_PROJECT_ID"]], indirect=True)
|
||||
def test_vertex_ai_text_embedding_init_with_empty_project_id(vertex_ai_unit_test_env) -> None:
|
||||
"""Test initialization of VertexAITextEmbedding with an empty project id"""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
VertexAITextEmbedding(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(vertex_ai_unit_test_env) -> None:
|
||||
vertex_ai_text_embedding = VertexAITextEmbedding()
|
||||
assert vertex_ai_text_embedding.get_prompt_execution_settings_class() == VertexAIEmbeddingPromptExecutionSettings
|
||||
|
||||
|
||||
# endregion init
|
||||
|
||||
|
||||
@patch.object(TextEmbeddingModel, "from_pretrained")
|
||||
@patch.object(MockTextEmbeddingModel, "get_embeddings_async", new_callable=AsyncMock)
|
||||
async def test_embedding(mock_embedding_client, mock_from_pretrained, vertex_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly."""
|
||||
mock_from_pretrained.return_value = MockTextEmbeddingModel()
|
||||
mock_embedding_client.return_value = [TextEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
|
||||
settings = VertexAIEmbeddingPromptExecutionSettings()
|
||||
|
||||
vertex_ai_text_embedding = VertexAITextEmbedding()
|
||||
response: ndarray = await vertex_ai_text_embedding.generate_embeddings(
|
||||
[prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert len(response) == 1
|
||||
assert response.all() == array([0.1, 0.2, 0.3]).all()
|
||||
mock_embedding_client.assert_called_once_with([prompt])
|
||||
|
||||
|
||||
@patch.object(TextEmbeddingModel, "from_pretrained")
|
||||
@patch.object(MockTextEmbeddingModel, "get_embeddings_async", new_callable=AsyncMock)
|
||||
async def test_embedding_with_settings(mock_embedding_client, mock_from_pretrained, vertex_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly."""
|
||||
mock_from_pretrained.return_value = MockTextEmbeddingModel()
|
||||
mock_embedding_client.return_value = [TextEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
|
||||
settings = VertexAIEmbeddingPromptExecutionSettings()
|
||||
settings.output_dimensionality = 3
|
||||
settings.auto_truncate = True
|
||||
|
||||
vertex_ai_text_embedding = VertexAITextEmbedding()
|
||||
response: ndarray = await vertex_ai_text_embedding.generate_embeddings(
|
||||
[prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert len(response) == 1
|
||||
assert response.all() == array([0.1, 0.2, 0.3]).all()
|
||||
mock_embedding_client.assert_called_once_with(
|
||||
[prompt],
|
||||
**settings.prepare_settings_dict(),
|
||||
)
|
||||
|
||||
|
||||
@patch.object(TextEmbeddingModel, "from_pretrained")
|
||||
@patch.object(MockTextEmbeddingModel, "get_embeddings_async", new_callable=AsyncMock)
|
||||
async def test_embedding_without_settings(mock_embedding_client, mock_from_pretrained, vertex_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly without settings."""
|
||||
mock_from_pretrained.return_value = MockTextEmbeddingModel()
|
||||
mock_embedding_client.return_value = [TextEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
|
||||
vertex_ai_text_embedding = VertexAITextEmbedding()
|
||||
response: ndarray = await vertex_ai_text_embedding.generate_embeddings([prompt])
|
||||
|
||||
assert len(response) == 1
|
||||
assert response.all() == array([0.1, 0.2, 0.3]).all()
|
||||
mock_embedding_client.assert_called_once_with([prompt])
|
||||
|
||||
|
||||
@patch.object(TextEmbeddingModel, "from_pretrained")
|
||||
@patch.object(MockTextEmbeddingModel, "get_embeddings_async", new_callable=AsyncMock)
|
||||
async def test_embedding_list_input(mock_embedding_client, mock_from_pretrained, vertex_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly with a list of prompts."""
|
||||
mock_from_pretrained.return_value = MockTextEmbeddingModel()
|
||||
mock_embedding_client.return_value = [TextEmbedding(values=[0.1, 0.2, 0.3]), TextEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
|
||||
vertex_ai_text_embedding = VertexAITextEmbedding()
|
||||
response: ndarray = await vertex_ai_text_embedding.generate_embeddings([prompt, prompt])
|
||||
|
||||
assert len(response) == 2
|
||||
assert response.all() == array([[0.1, 0.2, 0.3], [0.1, 0.2, 0.3]]).all()
|
||||
mock_embedding_client.assert_called_once_with([prompt, prompt])
|
||||
|
||||
|
||||
@patch.object(TextEmbeddingModel, "from_pretrained")
|
||||
@patch.object(MockTextEmbeddingModel, "get_embeddings_async", new_callable=AsyncMock)
|
||||
async def test_raw_embedding(mock_embedding_client, mock_from_pretrained, vertex_ai_unit_test_env, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly."""
|
||||
mock_from_pretrained.return_value = MockTextEmbeddingModel()
|
||||
mock_embedding_client.return_value = [TextEmbedding(values=[0.1, 0.2, 0.3])]
|
||||
|
||||
settings = VertexAIEmbeddingPromptExecutionSettings()
|
||||
|
||||
vertex_ai_text_embedding = VertexAITextEmbedding()
|
||||
response: ndarray = await vertex_ai_text_embedding.generate_raw_embeddings([prompt], settings)
|
||||
|
||||
assert len(response) == 1
|
||||
assert response[0] == [0.1, 0.2, 0.3]
|
||||
mock_embedding_client.assert_called_once_with([prompt])
|
||||
@@ -0,0 +1,199 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from google.cloud.aiplatform_v1beta1.types.content import Candidate
|
||||
from vertexai.generative_models import FunctionDeclaration, Part
|
||||
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.services.utils import (
|
||||
finish_reason_from_vertex_ai_to_semantic_kernel,
|
||||
format_assistant_message,
|
||||
format_user_message,
|
||||
kernel_function_metadata_to_vertex_ai_function_call_format,
|
||||
)
|
||||
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.image_content import ImageContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.contents.utils.finish_reason import FinishReason
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInvalidRequestError
|
||||
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
|
||||
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
|
||||
|
||||
|
||||
def test_finish_reason_from_vertex_ai_to_semantic_kernel():
|
||||
"""Test finish_reason_from_vertex_ai_to_semantic_kernel."""
|
||||
assert finish_reason_from_vertex_ai_to_semantic_kernel(Candidate.FinishReason.STOP) == FinishReason.STOP
|
||||
assert finish_reason_from_vertex_ai_to_semantic_kernel(Candidate.FinishReason.MAX_TOKENS) == FinishReason.LENGTH
|
||||
assert finish_reason_from_vertex_ai_to_semantic_kernel(Candidate.FinishReason.SAFETY) == FinishReason.CONTENT_FILTER
|
||||
assert finish_reason_from_vertex_ai_to_semantic_kernel(Candidate.FinishReason.OTHER) is None
|
||||
|
||||
|
||||
def test_format_user_message():
|
||||
"""Test format_user_message."""
|
||||
user_message = ChatMessageContent(role=AuthorRole.USER, content="User message")
|
||||
formatted_user_message = format_user_message(user_message)
|
||||
|
||||
assert len(formatted_user_message) == 1
|
||||
assert isinstance(formatted_user_message[0], Part)
|
||||
assert formatted_user_message[0].text == "User message"
|
||||
|
||||
# Test with an image content
|
||||
image_content = ImageContent(data="image data", mime_type="image/png")
|
||||
user_message = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[
|
||||
TextContent(text="Text content"),
|
||||
image_content,
|
||||
],
|
||||
)
|
||||
formatted_user_message = format_user_message(user_message)
|
||||
|
||||
assert len(formatted_user_message) == 2
|
||||
assert isinstance(formatted_user_message[0], Part)
|
||||
assert formatted_user_message[0].text == "Text content"
|
||||
assert isinstance(formatted_user_message[1], Part)
|
||||
assert formatted_user_message[1].inline_data.mime_type == "image/png"
|
||||
assert formatted_user_message[1].inline_data.data == image_content.data
|
||||
|
||||
|
||||
def test_format_user_message_throws_with_unsupported_items() -> None:
|
||||
"""Test format_user_message with unsupported items."""
|
||||
# Test with unsupported items, any item other than TextContent and ImageContent should raise an error
|
||||
user_message = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[
|
||||
FunctionCallContent(),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
format_user_message(user_message)
|
||||
|
||||
# Test with an ImageContent that has no data_uri
|
||||
user_message = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[
|
||||
ImageContent(data_uri=""),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
format_user_message(user_message)
|
||||
|
||||
|
||||
def test_format_assistant_message() -> None:
|
||||
assistant_message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
TextContent(text="test"),
|
||||
FunctionCallContent(name="test_function", arguments={}),
|
||||
ImageContent(data="image data", mime_type="image/png"),
|
||||
],
|
||||
)
|
||||
|
||||
formatted_assistant_message = format_assistant_message(assistant_message)
|
||||
assert isinstance(formatted_assistant_message, list)
|
||||
assert len(formatted_assistant_message) == 3
|
||||
assert isinstance(formatted_assistant_message[0], Part)
|
||||
assert formatted_assistant_message[0].text == "test"
|
||||
assert isinstance(formatted_assistant_message[1], Part)
|
||||
assert formatted_assistant_message[1].function_call.name == "test_function"
|
||||
assert formatted_assistant_message[1].function_call.args == {}
|
||||
assert isinstance(formatted_assistant_message[2], Part)
|
||||
assert formatted_assistant_message[2].inline_data
|
||||
|
||||
|
||||
def test_format_assistant_message_with_unsupported_items() -> None:
|
||||
assistant_message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
FunctionResultContent(id="test_id", function_name="test_function"),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
format_assistant_message(assistant_message)
|
||||
|
||||
|
||||
def test_format_assistant_message_with_thought_signature() -> None:
|
||||
"""Test that thought_signature is preserved in function call parts for Vertex AI."""
|
||||
import base64
|
||||
|
||||
thought_sig = base64.b64encode(b"test_thought_signature_data").decode("utf-8")
|
||||
assistant_message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
FunctionCallContent(
|
||||
name="test_function",
|
||||
arguments={"arg1": "value1"},
|
||||
metadata={"thought_signature": thought_sig},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
formatted = format_assistant_message(assistant_message)
|
||||
assert len(formatted) == 1
|
||||
assert isinstance(formatted[0], Part)
|
||||
assert formatted[0].function_call.name == "test_function"
|
||||
assert formatted[0].function_call.args == {"arg1": "value1"}
|
||||
part_dict = formatted[0].to_dict()
|
||||
assert "thought_signature" in part_dict
|
||||
assert part_dict["thought_signature"] == thought_sig
|
||||
|
||||
|
||||
def test_format_assistant_message_without_thought_signature() -> None:
|
||||
"""Test that function calls without thought_signature still work for Vertex AI."""
|
||||
assistant_message = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
FunctionCallContent(
|
||||
name="test_function",
|
||||
arguments={"arg1": "value1"},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
formatted = format_assistant_message(assistant_message)
|
||||
assert len(formatted) == 1
|
||||
assert isinstance(formatted[0], Part)
|
||||
assert formatted[0].function_call.name == "test_function"
|
||||
assert formatted[0].function_call.args == {"arg1": "value1"}
|
||||
part_dict = formatted[0].to_dict()
|
||||
assert "thought_signature" not in part_dict
|
||||
|
||||
|
||||
def test_vertex_ai_function_call_format_sanitizes_anyof_schema() -> None:
|
||||
"""Integration test: anyOf in param schema_data is sanitized in the FunctionDeclaration."""
|
||||
metadata = KernelFunctionMetadata(
|
||||
name="test_func",
|
||||
description="A test function",
|
||||
is_prompt=False,
|
||||
parameters=[
|
||||
KernelParameterMetadata(
|
||||
name="messages",
|
||||
description="The user messages",
|
||||
is_required=True,
|
||||
schema_data={
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
],
|
||||
"description": "The user messages",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
result = kernel_function_metadata_to_vertex_ai_function_call_format(metadata)
|
||||
assert isinstance(result, FunctionDeclaration)
|
||||
|
||||
|
||||
def test_vertex_ai_function_call_format_empty_parameters() -> None:
|
||||
"""Integration test: metadata with no parameters produces empty properties, no crash."""
|
||||
metadata = KernelFunctionMetadata(
|
||||
name="no_params_func",
|
||||
description="No parameters",
|
||||
is_prompt=False,
|
||||
parameters=[],
|
||||
)
|
||||
result = kernel_function_metadata_to_vertex_ai_function_call_format(metadata)
|
||||
assert isinstance(result, FunctionDeclaration)
|
||||
@@ -0,0 +1,136 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai import (
|
||||
VertexAIChatPromptExecutionSettings,
|
||||
VertexAIPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.google.vertex_ai.vertex_ai_prompt_execution_settings import (
|
||||
VertexAIEmbeddingPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
|
||||
|
||||
def test_default_vertex_ai_prompt_execution_settings():
|
||||
settings = VertexAIPromptExecutionSettings()
|
||||
|
||||
assert settings.stop_sequences is None
|
||||
assert settings.response_mime_type is None
|
||||
assert settings.response_schema is None
|
||||
assert settings.candidate_count is None
|
||||
assert settings.max_output_tokens is None
|
||||
assert settings.temperature is None
|
||||
assert settings.top_p is None
|
||||
assert settings.top_k is None
|
||||
|
||||
|
||||
def test_custom_vertex_ai_prompt_execution_settings():
|
||||
settings = VertexAIPromptExecutionSettings(
|
||||
stop_sequences=["world"],
|
||||
response_mime_type="text/plain",
|
||||
candidate_count=1,
|
||||
max_output_tokens=128,
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
top_k=10,
|
||||
)
|
||||
|
||||
assert settings.stop_sequences == ["world"]
|
||||
assert settings.response_mime_type == "text/plain"
|
||||
assert settings.candidate_count == 1
|
||||
assert settings.max_output_tokens == 128
|
||||
assert settings.temperature == 0.5
|
||||
assert settings.top_p == 0.5
|
||||
assert settings.top_k == 10
|
||||
|
||||
|
||||
def test_vertex_ai_prompt_execution_settings_from_default_completion_config():
|
||||
settings = PromptExecutionSettings(service_id="test_service")
|
||||
chat_settings = VertexAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.stop_sequences is None
|
||||
assert chat_settings.response_mime_type is None
|
||||
assert chat_settings.response_schema is None
|
||||
assert chat_settings.candidate_count is None
|
||||
assert chat_settings.max_output_tokens is None
|
||||
assert chat_settings.temperature is None
|
||||
assert chat_settings.top_p is None
|
||||
assert chat_settings.top_k is None
|
||||
|
||||
|
||||
def test_vertex_ai_prompt_execution_settings_from_openai_prompt_execution_settings():
|
||||
chat_settings = VertexAIChatPromptExecutionSettings(service_id="test_service", temperature=1.0)
|
||||
new_settings = VertexAIPromptExecutionSettings(service_id="test_2", temperature=0.0)
|
||||
chat_settings.update_from_prompt_execution_settings(new_settings)
|
||||
|
||||
assert chat_settings.service_id == "test_2"
|
||||
assert chat_settings.temperature == 0.0
|
||||
|
||||
|
||||
def test_vertex_ai_prompt_execution_settings_from_custom_completion_config():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"stop_sequences": ["world"],
|
||||
"response_mime_type": "text/plain",
|
||||
"candidate_count": 1,
|
||||
"max_output_tokens": 128,
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"top_k": 10,
|
||||
},
|
||||
)
|
||||
chat_settings = VertexAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.stop_sequences == ["world"]
|
||||
assert chat_settings.response_mime_type == "text/plain"
|
||||
assert chat_settings.candidate_count == 1
|
||||
assert chat_settings.max_output_tokens == 128
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.top_k == 10
|
||||
|
||||
|
||||
def test_vertex_ai_chat_prompt_execution_settings_from_custom_completion_config_with_functions():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"tools": [],
|
||||
},
|
||||
)
|
||||
chat_settings = VertexAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.tools == []
|
||||
|
||||
|
||||
def test_create_options():
|
||||
settings = VertexAIChatPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"stop_sequences": ["world"],
|
||||
"response_mime_type": "text/plain",
|
||||
"candidate_count": 1,
|
||||
"max_output_tokens": 128,
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"top_k": 10,
|
||||
},
|
||||
)
|
||||
options = settings.prepare_settings_dict()
|
||||
|
||||
assert options["stop_sequences"] == ["world"]
|
||||
assert options["response_mime_type"] == "text/plain"
|
||||
assert options["candidate_count"] == 1
|
||||
assert options["max_output_tokens"] == 128
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.5
|
||||
assert options["top_k"] == 10
|
||||
assert "tools" not in options
|
||||
assert "tool_config" not in options
|
||||
|
||||
|
||||
def test_default_vertex_ai_embedding_prompt_execution_settings():
|
||||
settings = VertexAIEmbeddingPromptExecutionSettings()
|
||||
|
||||
assert settings.output_dimensionality is None
|
||||
assert settings.auto_truncate is None
|
||||
@@ -0,0 +1,219 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from threading import Thread
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from transformers import AutoTokenizer, TextIteratorStreamer
|
||||
|
||||
from semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion import HuggingFaceTextCompletion
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.exceptions import KernelInvokeException, ServiceResponseException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "task", "input_str"),
|
||||
[
|
||||
(
|
||||
"patrickvonplaten/t5-tiny-random",
|
||||
"text2text-generation",
|
||||
"translate English to Dutch: Hello, how are you?",
|
||||
),
|
||||
(
|
||||
"Falconsai/text_summarization",
|
||||
"summarization",
|
||||
"""
|
||||
Summarize: Whales are fully aquatic, open-ocean animals:
|
||||
they can feed, mate, give birth, suckle and raise their young at sea.
|
||||
Whales range in size from the 2.6 metres (8.5 ft) and 135 kilograms (298 lb)
|
||||
dwarf sperm whale to the 29.9 metres (98 ft) and 190 tonnes (210 short tons) blue whale,
|
||||
which is the largest known animal that has ever lived. The sperm whale is the largest
|
||||
toothed predator on Earth. Several whale species exhibit sexual dimorphism,
|
||||
in that the females are larger than males.
|
||||
""",
|
||||
),
|
||||
("HuggingFaceM4/tiny-random-LlamaForCausalLM", "text-generation", "Hello, I like sleeping and "),
|
||||
],
|
||||
ids=["text2text-generation", "summarization", "text-generation"],
|
||||
)
|
||||
async def test_text_completion(model_name, task, input_str):
|
||||
kernel = Kernel()
|
||||
|
||||
ret = {"summary_text": "test"} if task == "summarization" else {"generated_text": "test"}
|
||||
mock_pipeline = Mock(return_value=ret)
|
||||
|
||||
# Configure LLM service
|
||||
with patch("semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.pipeline") as patched_pipeline:
|
||||
patched_pipeline.return_value = mock_pipeline
|
||||
service = HuggingFaceTextCompletion(service_id=model_name, ai_model_id=model_name, task=task)
|
||||
kernel.add_service(
|
||||
service=service,
|
||||
)
|
||||
|
||||
exec_settings = PromptExecutionSettings(service_id=model_name, extension_data={"max_new_tokens": 25})
|
||||
|
||||
# Define semantic function using SK prompt template language
|
||||
prompt = "{{$input}}"
|
||||
|
||||
prompt_template_config = PromptTemplateConfig(template=prompt, execution_settings=exec_settings)
|
||||
|
||||
kernel.add_function(
|
||||
prompt_template_config=prompt_template_config,
|
||||
function_name="TestFunction",
|
||||
plugin_name="TestPlugin",
|
||||
prompt_execution_settings=exec_settings,
|
||||
)
|
||||
|
||||
arguments = KernelArguments(input=input_str)
|
||||
|
||||
await kernel.invoke(function_name="TestFunction", plugin_name="TestPlugin", arguments=arguments)
|
||||
assert mock_pipeline.call_args.args[0] == input_str
|
||||
|
||||
|
||||
async def test_text_completion_throws():
|
||||
kernel = Kernel()
|
||||
|
||||
model_name = "patrickvonplaten/t5-tiny-random"
|
||||
task = "text2text-generation"
|
||||
input_str = "translate English to Dutch: Hello, how are you?"
|
||||
|
||||
with patch("semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.pipeline") as patched_pipeline:
|
||||
mock_generator = Mock()
|
||||
mock_generator.side_effect = Exception("Test exception")
|
||||
patched_pipeline.return_value = mock_generator
|
||||
service = HuggingFaceTextCompletion(service_id=model_name, ai_model_id=model_name, task=task)
|
||||
kernel.add_service(service=service)
|
||||
|
||||
exec_settings = PromptExecutionSettings(service_id=model_name, extension_data={"max_new_tokens": 25})
|
||||
|
||||
prompt = "{{$input}}"
|
||||
prompt_template_config = PromptTemplateConfig(template=prompt, execution_settings=exec_settings)
|
||||
|
||||
kernel.add_function(
|
||||
prompt_template_config=prompt_template_config,
|
||||
function_name="TestFunction",
|
||||
plugin_name="TestPlugin",
|
||||
prompt_execution_settings=exec_settings,
|
||||
)
|
||||
|
||||
arguments = KernelArguments(input=input_str)
|
||||
|
||||
with pytest.raises(
|
||||
KernelInvokeException, match="Error occurred while invoking function: 'TestPlugin-TestFunction'"
|
||||
):
|
||||
await kernel.invoke(function_name="TestFunction", plugin_name="TestPlugin", arguments=arguments)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "task", "input_str"),
|
||||
[
|
||||
(
|
||||
"patrickvonplaten/t5-tiny-random",
|
||||
"text2text-generation",
|
||||
"translate English to Dutch: Hello, how are you?",
|
||||
),
|
||||
("HuggingFaceM4/tiny-random-LlamaForCausalLM", "text-generation", "Hello, I like sleeping and "),
|
||||
],
|
||||
ids=["text2text-generation", "text-generation"],
|
||||
)
|
||||
async def test_text_completion_streaming(model_name, task, input_str):
|
||||
ret = {"summary_text": "test"} if task == "summarization" else {"generated_text": "test"}
|
||||
mock_pipeline = Mock(return_value=ret)
|
||||
|
||||
mock_streamer = MagicMock(spec=TextIteratorStreamer)
|
||||
mock_streamer.__iter__.return_value = iter(["mocked_text"])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.pipeline",
|
||||
return_value=mock_pipeline,
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.Thread",
|
||||
side_effect=Mock(spec=Thread),
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.AutoTokenizer",
|
||||
side_effect=Mock(spec=AutoTokenizer),
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.TextIteratorStreamer",
|
||||
return_value=mock_streamer,
|
||||
) as mock_stream,
|
||||
):
|
||||
mock_stream.return_value = mock_streamer
|
||||
service = HuggingFaceTextCompletion(service_id=model_name, ai_model_id=model_name, task=task)
|
||||
prompt = "test prompt"
|
||||
exec_settings = PromptExecutionSettings(service_id=model_name, extension_data={"max_new_tokens": 25})
|
||||
|
||||
result = []
|
||||
async for content in service.get_streaming_text_contents(prompt, exec_settings):
|
||||
result.append(content)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0][0].inner_content == "mocked_text"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "task", "input_str"),
|
||||
[
|
||||
(
|
||||
"patrickvonplaten/t5-tiny-random",
|
||||
"text2text-generation",
|
||||
"translate English to Dutch: Hello, how are you?",
|
||||
),
|
||||
("HuggingFaceM4/tiny-random-LlamaForCausalLM", "text-generation", "Hello, I like sleeping and "),
|
||||
],
|
||||
ids=["text2text-generation", "text-generation"],
|
||||
)
|
||||
async def test_text_completion_streaming_throws(model_name, task, input_str):
|
||||
ret = {"summary_text": "test"} if task == "summarization" else {"generated_text": "test"}
|
||||
mock_pipeline = Mock(return_value=ret)
|
||||
|
||||
mock_streamer = MagicMock(spec=TextIteratorStreamer)
|
||||
mock_streamer.__iter__.return_value = Exception()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.pipeline",
|
||||
return_value=mock_pipeline,
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.Thread",
|
||||
side_effect=Exception(),
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.TextIteratorStreamer",
|
||||
return_value=mock_streamer,
|
||||
) as mock_stream,
|
||||
):
|
||||
mock_stream.return_value = mock_streamer
|
||||
service = HuggingFaceTextCompletion(service_id=model_name, ai_model_id=model_name, task=task)
|
||||
prompt = "test prompt"
|
||||
exec_settings = PromptExecutionSettings(service_id=model_name, extension_data={"max_new_tokens": 25})
|
||||
|
||||
with pytest.raises(ServiceResponseException, match=("Hugging Face completion failed")):
|
||||
async for _ in service.get_streaming_text_contents(prompt, exec_settings):
|
||||
pass
|
||||
|
||||
|
||||
def test_hugging_face_text_completion_init():
|
||||
with (
|
||||
patch("semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.pipeline") as patched_pipeline,
|
||||
patch(
|
||||
"semantic_kernel.connectors.ai.hugging_face.services.hf_text_completion.torch.cuda.is_available"
|
||||
) as mock_torch_cuda_is_available,
|
||||
):
|
||||
patched_pipeline.return_value = patched_pipeline
|
||||
mock_torch_cuda_is_available.return_value = False
|
||||
|
||||
ai_model_id = "test-model"
|
||||
task = "summarization"
|
||||
device = -1
|
||||
|
||||
service = HuggingFaceTextCompletion(service_id="test", ai_model_id=ai_model_id, task=task, device=device)
|
||||
|
||||
assert service is not None
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from numpy import array, ndarray
|
||||
|
||||
from semantic_kernel.connectors.ai.hugging_face.services.hf_text_embedding import HuggingFaceTextEmbedding
|
||||
from semantic_kernel.exceptions import ServiceResponseException
|
||||
|
||||
|
||||
def test_huggingface_text_embedding_initialization():
|
||||
model_name = "sentence-transformers/all-MiniLM-L6-v2"
|
||||
device = -1
|
||||
|
||||
with patch("sentence_transformers.SentenceTransformer") as mock_transformer:
|
||||
mock_instance = mock_transformer.return_value
|
||||
service = HuggingFaceTextEmbedding(service_id="test", ai_model_id=model_name, device=device)
|
||||
|
||||
assert service.ai_model_id == model_name
|
||||
assert service.device == "cpu"
|
||||
assert service.generator == mock_instance
|
||||
mock_transformer.assert_called_once_with(model_name_or_path=model_name, device="cpu")
|
||||
|
||||
|
||||
async def test_generate_embeddings_success():
|
||||
model_name = "sentence-transformers/all-MiniLM-L6-v2"
|
||||
device = -1
|
||||
texts = ["Hello world!", "How are you?"]
|
||||
mock_embeddings = array([[0.1, 0.2], [0.3, 0.4]])
|
||||
|
||||
with patch("sentence_transformers.SentenceTransformer") as mock_transformer:
|
||||
mock_instance = mock_transformer.return_value
|
||||
mock_instance.encode.return_value = mock_embeddings
|
||||
|
||||
service = HuggingFaceTextEmbedding(service_id="test", ai_model_id=model_name, device=device)
|
||||
embeddings = await service.generate_embeddings(texts)
|
||||
|
||||
assert isinstance(embeddings, ndarray)
|
||||
assert embeddings.shape == (2, 2)
|
||||
assert (embeddings == mock_embeddings).all()
|
||||
|
||||
|
||||
async def test_generate_embeddings_throws():
|
||||
model_name = "sentence-transformers/all-MiniLM-L6-v2"
|
||||
device = -1
|
||||
texts = ["Hello world!", "How are you?"]
|
||||
|
||||
with patch("sentence_transformers.SentenceTransformer") as mock_transformer:
|
||||
mock_instance = mock_transformer.return_value
|
||||
mock_instance.encode.side_effect = Exception("Test exception")
|
||||
|
||||
service = HuggingFaceTextEmbedding(service_id="test", ai_model_id=model_name, device=device)
|
||||
|
||||
with pytest.raises(ServiceResponseException, match="Hugging Face embeddings failed"):
|
||||
await service.generate_embeddings(texts)
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from mistralai import CompletionEvent, Mistral
|
||||
from mistralai.models import (
|
||||
AssistantMessage,
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionResponse,
|
||||
CompletionChunk,
|
||||
CompletionResponseStreamChoice,
|
||||
DeltaMessage,
|
||||
UsageInfo,
|
||||
)
|
||||
|
||||
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
||||
from semantic_kernel.connectors.ai.function_call_choice_configuration import FunctionCallChoiceConfiguration
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior, FunctionChoiceType
|
||||
from semantic_kernel.connectors.ai.mistral_ai.prompt_execution_settings.mistral_ai_prompt_execution_settings import (
|
||||
MistralAIChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.mistral_ai.services.mistral_ai_chat_completion import MistralAIChatCompletion
|
||||
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import (
|
||||
OpenAIChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.contents import FunctionCallContent, TextContent
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
ServiceResponseException,
|
||||
)
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function import KernelFunction
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings() -> MistralAIChatPromptExecutionSettings:
|
||||
return MistralAIChatPromptExecutionSettings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mistral_ai_client_completion() -> Mistral:
|
||||
client = MagicMock(spec=Mistral)
|
||||
client.chat = MagicMock()
|
||||
|
||||
# Use proper ChatCompletionResponse type so isinstance checks pass
|
||||
chat_completion_response = ChatCompletionResponse(
|
||||
id="test_id",
|
||||
object="object",
|
||||
created=12345,
|
||||
usage=UsageInfo(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
||||
model="test_model_id",
|
||||
choices=[
|
||||
ChatCompletionChoice(
|
||||
index=0,
|
||||
message=AssistantMessage(role="assistant", content="Test"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
client.chat.complete_async = AsyncMock(return_value=chat_completion_response)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mistral_ai_client_completion_stream() -> Mistral:
|
||||
client = MagicMock(spec=Mistral)
|
||||
client.chat = MagicMock()
|
||||
|
||||
# Use proper CompletionEvent/CompletionChunk types so isinstance checks pass
|
||||
mock_chunk = CompletionEvent(
|
||||
data=CompletionChunk(
|
||||
id="test_chunk",
|
||||
created=12345,
|
||||
model="test_model_id",
|
||||
choices=[
|
||||
CompletionResponseStreamChoice(
|
||||
index=0,
|
||||
delta=DeltaMessage(role="assistant", content="Test"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
async def mock_stream_async(**kwargs):
|
||||
yield mock_chunk
|
||||
|
||||
client.chat.stream_async = AsyncMock(return_value=mock_stream_async())
|
||||
return client
|
||||
|
||||
|
||||
async def test_complete_chat_contents(
|
||||
kernel: Kernel,
|
||||
mock_settings: MistralAIChatPromptExecutionSettings,
|
||||
mock_mistral_ai_client_completion: Mistral,
|
||||
):
|
||||
chat_history = MagicMock()
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = MistralAIChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=mock_mistral_ai_client_completion
|
||||
)
|
||||
|
||||
content: list[ChatMessageContent] = await chat_completion_base.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=mock_settings, kernel=kernel, arguments=arguments
|
||||
)
|
||||
assert content is not None
|
||||
|
||||
|
||||
mock_message_text_content = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[TextContent(text="test")])
|
||||
|
||||
mock_message_function_call = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[
|
||||
FunctionCallContent(
|
||||
name="test",
|
||||
arguments={"key": "test"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"function_choice_behavior,model_responses,expected_result",
|
||||
[
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Auto(),
|
||||
[[mock_message_function_call], [mock_message_text_content]],
|
||||
TextContent,
|
||||
id="auto",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Auto(auto_invoke=False),
|
||||
[[mock_message_function_call]],
|
||||
FunctionCallContent,
|
||||
id="auto_none_invoke",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Required(auto_invoke=False),
|
||||
[[mock_message_function_call]],
|
||||
FunctionCallContent,
|
||||
id="required_none_invoke",
|
||||
),
|
||||
pytest.param(FunctionChoiceBehavior.NoneInvoke(), [[mock_message_text_content]], TextContent, id="none"),
|
||||
],
|
||||
)
|
||||
async def test_complete_chat_contents_function_call_behavior_tool_call(
|
||||
kernel: Kernel,
|
||||
mock_settings: MistralAIChatPromptExecutionSettings,
|
||||
function_choice_behavior: FunctionChoiceBehavior,
|
||||
model_responses,
|
||||
expected_result,
|
||||
):
|
||||
kernel.add_function("test", kernel_function(lambda key: "test", name="test"))
|
||||
mock_settings.function_choice_behavior = function_choice_behavior
|
||||
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = MistralAIChatCompletion(ai_model_id="test_model_id", service_id="test", api_key="")
|
||||
|
||||
with (
|
||||
patch.object(chat_completion_base, "_inner_get_chat_message_contents", side_effect=model_responses),
|
||||
):
|
||||
response: list[ChatMessageContent] = await chat_completion_base.get_chat_message_contents(
|
||||
chat_history=ChatHistory(system_message="Test"), settings=mock_settings, kernel=kernel, arguments=arguments
|
||||
)
|
||||
|
||||
assert all(isinstance(content, expected_result) for content in response[0].items)
|
||||
|
||||
|
||||
async def test_complete_chat_contents_function_call_behavior_without_kernel(
|
||||
mock_settings: MistralAIChatPromptExecutionSettings,
|
||||
mock_mistral_ai_client_completion: Mistral,
|
||||
):
|
||||
chat_history = MagicMock()
|
||||
chat_completion_base = MistralAIChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=mock_mistral_ai_client_completion
|
||||
)
|
||||
|
||||
mock_settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
|
||||
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
await chat_completion_base.get_chat_message_contents(chat_history=chat_history, settings=mock_settings)
|
||||
|
||||
|
||||
async def test_complete_chat_stream_contents(
|
||||
kernel: Kernel,
|
||||
mock_settings: MistralAIChatPromptExecutionSettings,
|
||||
mock_mistral_ai_client_completion_stream: Mistral,
|
||||
):
|
||||
chat_history = MagicMock()
|
||||
arguments = KernelArguments()
|
||||
|
||||
chat_completion_base = MistralAIChatCompletion(
|
||||
ai_model_id="test_model_id",
|
||||
service_id="test",
|
||||
api_key="",
|
||||
async_client=mock_mistral_ai_client_completion_stream,
|
||||
)
|
||||
|
||||
async for content in chat_completion_base.get_streaming_chat_message_contents(
|
||||
chat_history, mock_settings, kernel=kernel, arguments=arguments
|
||||
):
|
||||
assert content is not None
|
||||
|
||||
|
||||
mock_message_function_call = StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, items=[FunctionCallContent(name="test")], choice_index="0"
|
||||
)
|
||||
|
||||
mock_message_text_content = StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, items=[TextContent(text="test")], choice_index="0"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"function_choice_behavior,model_responses,expected_result",
|
||||
[
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Auto(),
|
||||
[[mock_message_function_call], [mock_message_text_content]],
|
||||
TextContent,
|
||||
id="auto",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Auto(auto_invoke=False),
|
||||
[[mock_message_function_call]],
|
||||
FunctionCallContent,
|
||||
id="auto_none_invoke",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionChoiceBehavior.Required(auto_invoke=False),
|
||||
[[mock_message_function_call]],
|
||||
FunctionCallContent,
|
||||
id="required_none_invoke",
|
||||
),
|
||||
pytest.param(FunctionChoiceBehavior.NoneInvoke(), [[mock_message_text_content]], TextContent, id="none"),
|
||||
],
|
||||
)
|
||||
async def test_complete_chat_contents_streaming_function_call_behavior_tool_call(
|
||||
kernel: Kernel,
|
||||
mock_settings: MistralAIChatPromptExecutionSettings,
|
||||
function_choice_behavior: FunctionChoiceBehavior,
|
||||
model_responses,
|
||||
expected_result,
|
||||
):
|
||||
mock_settings.function_choice_behavior = function_choice_behavior
|
||||
|
||||
# Mock sequence of model responses
|
||||
generator_mocks = []
|
||||
for mock_message in model_responses:
|
||||
generator_mock = MagicMock()
|
||||
generator_mock.__aiter__.return_value = [mock_message]
|
||||
generator_mocks.append(generator_mock)
|
||||
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = MistralAIChatCompletion(ai_model_id="test_model_id", service_id="test", api_key="")
|
||||
|
||||
with patch.object(chat_completion_base, "_inner_get_streaming_chat_message_contents", side_effect=generator_mocks):
|
||||
messages = []
|
||||
async for chunk in chat_completion_base.get_streaming_chat_message_contents(
|
||||
chat_history=ChatHistory(system_message="Test"), settings=mock_settings, kernel=kernel, arguments=arguments
|
||||
):
|
||||
messages.append(chunk)
|
||||
|
||||
response = messages[-1]
|
||||
assert all(isinstance(content, expected_result) for content in response[0].items)
|
||||
|
||||
|
||||
async def test_mistral_ai_sdk_exception(kernel: Kernel, mock_settings: MistralAIChatPromptExecutionSettings):
|
||||
chat_history = MagicMock()
|
||||
arguments = KernelArguments()
|
||||
client = MagicMock(spec=Mistral)
|
||||
client.chat = MagicMock()
|
||||
client.chat.complete_async.side_effect = Exception("Test Exception")
|
||||
|
||||
chat_completion_base = MistralAIChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=client
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await chat_completion_base.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=mock_settings, kernel=kernel, arguments=arguments
|
||||
)
|
||||
|
||||
|
||||
async def test_mistral_ai_sdk_exception_streaming(kernel: Kernel, mock_settings: MistralAIChatPromptExecutionSettings):
|
||||
chat_history = MagicMock()
|
||||
arguments = KernelArguments()
|
||||
client = MagicMock(spec=Mistral)
|
||||
client.chat = MagicMock()
|
||||
client.chat.chat_stream.side_effect = Exception("Test Exception")
|
||||
|
||||
chat_completion_base = MistralAIChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=client
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceResponseException):
|
||||
async for content in chat_completion_base.get_streaming_chat_message_contents(
|
||||
chat_history, mock_settings, kernel=kernel, arguments=arguments
|
||||
):
|
||||
assert content is not None
|
||||
|
||||
|
||||
def test_mistral_ai_chat_completion_init(mistralai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
mistral_ai_chat_completion = MistralAIChatCompletion()
|
||||
|
||||
assert mistral_ai_chat_completion.ai_model_id == mistralai_unit_test_env["MISTRALAI_CHAT_MODEL_ID"]
|
||||
api_key = mistralai_unit_test_env["MISTRALAI_API_KEY"]
|
||||
assert mistral_ai_chat_completion.async_client.sdk_configuration.security.api_key == api_key
|
||||
assert isinstance(mistral_ai_chat_completion, ChatCompletionClientBase)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_API_KEY", "MISTRALAI_CHAT_MODEL_ID"]], indirect=True)
|
||||
def test_mistral_ai_chat_completion_init_constructor(mistralai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
mistral_ai_chat_completion = MistralAIChatCompletion(
|
||||
api_key="overwrite_api_key",
|
||||
ai_model_id="overwrite_model_id",
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
assert mistral_ai_chat_completion.ai_model_id == "overwrite_model_id"
|
||||
assert mistral_ai_chat_completion.async_client.sdk_configuration.security.api_key == "overwrite_api_key"
|
||||
assert isinstance(mistral_ai_chat_completion, ChatCompletionClientBase)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_API_KEY", "MISTRALAI_CHAT_MODEL_ID"]], indirect=True)
|
||||
def test_mistral_ai_chat_completion_init_constructor_missing_model(mistralai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
MistralAIChatCompletion(api_key="overwrite_api_key", env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_API_KEY", "MISTRALAI_CHAT_MODEL_ID"]], indirect=True)
|
||||
def test_mistral_ai_chat_completion_init_constructor_missing_api_key(mistralai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
MistralAIChatCompletion(ai_model_id="overwrite_model_id", env_file_path="test.env")
|
||||
|
||||
|
||||
def test_mistral_ai_chat_completion_init_hybrid(mistralai_unit_test_env) -> None:
|
||||
mistral_ai_chat_completion = MistralAIChatCompletion(
|
||||
ai_model_id="overwrite_model_id",
|
||||
env_file_path="test.env",
|
||||
)
|
||||
assert mistral_ai_chat_completion.ai_model_id == "overwrite_model_id"
|
||||
assert mistral_ai_chat_completion.async_client.sdk_configuration.security.api_key == "test_api_key"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_CHAT_MODEL_ID"]], indirect=True)
|
||||
def test_mistral_ai_chat_completion_init_with_empty_model_id(mistralai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
MistralAIChatCompletion(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(mistralai_unit_test_env):
|
||||
mistral_ai_chat_completion = MistralAIChatCompletion()
|
||||
prompt_execution_settings = mistral_ai_chat_completion.get_prompt_execution_settings_class()
|
||||
assert prompt_execution_settings == MistralAIChatPromptExecutionSettings
|
||||
|
||||
|
||||
async def test_with_different_execution_settings(kernel: Kernel, mock_mistral_ai_client_completion: MagicMock):
|
||||
chat_history = MagicMock()
|
||||
settings = OpenAIChatPromptExecutionSettings(temperature=0.2, seed=2)
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = MistralAIChatCompletion(
|
||||
ai_model_id="test_model_id", service_id="test", api_key="", async_client=mock_mistral_ai_client_completion
|
||||
)
|
||||
|
||||
await chat_completion_base.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=settings, kernel=kernel, arguments=arguments
|
||||
)
|
||||
assert mock_mistral_ai_client_completion.chat.complete_async.call_args.kwargs["temperature"] == 0.2
|
||||
assert mock_mistral_ai_client_completion.chat.complete_async.call_args.kwargs["seed"] == 2
|
||||
|
||||
|
||||
async def test_with_different_execution_settings_stream(
|
||||
kernel: Kernel, mock_mistral_ai_client_completion_stream: MagicMock
|
||||
):
|
||||
chat_history = MagicMock()
|
||||
settings = OpenAIChatPromptExecutionSettings(temperature=0.2, seed=2)
|
||||
arguments = KernelArguments()
|
||||
chat_completion_base = MistralAIChatCompletion(
|
||||
ai_model_id="test_model_id",
|
||||
service_id="test",
|
||||
api_key="",
|
||||
async_client=mock_mistral_ai_client_completion_stream,
|
||||
)
|
||||
|
||||
async for chunk in chat_completion_base.get_streaming_chat_message_contents(
|
||||
chat_history, settings, kernel=kernel, arguments=arguments
|
||||
):
|
||||
continue
|
||||
assert mock_mistral_ai_client_completion_stream.chat.stream_async.call_args.kwargs["temperature"] == 0.2
|
||||
assert mock_mistral_ai_client_completion_stream.chat.stream_async.call_args.kwargs["seed"] == 2
|
||||
|
||||
|
||||
async def test_mistral_ai_chat_completion_get_chat_message_contents_success():
|
||||
"""Test get_chat_message_contents with a successful ChatCompletionResponse."""
|
||||
|
||||
# Mock the response from the Mistral chat complete_async.
|
||||
mock_response = ChatCompletionResponse(
|
||||
id="some_id",
|
||||
object="object",
|
||||
created=12345,
|
||||
usage=UsageInfo(prompt_tokens=10, completion_tokens=20, total_tokens=30),
|
||||
model="test-model",
|
||||
choices=[
|
||||
ChatCompletionChoice(
|
||||
index=0,
|
||||
message=AssistantMessage(role="assistant", content="Hello!"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async_mock_client = MagicMock(spec=Mistral)
|
||||
async_mock_client.chat = MagicMock()
|
||||
async_mock_client.chat.complete_async = AsyncMock(return_value=mock_response)
|
||||
|
||||
chat_completion = MistralAIChatCompletion(
|
||||
ai_model_id="test-model",
|
||||
api_key="test_key",
|
||||
async_client=async_mock_client,
|
||||
)
|
||||
|
||||
# We create a ChatHistory.
|
||||
chat_history = ChatHistory()
|
||||
settings = MistralAIChatPromptExecutionSettings()
|
||||
|
||||
results = await chat_completion.get_chat_message_contents(chat_history, settings)
|
||||
|
||||
# We should have exactly one ChatMessageContent.
|
||||
assert len(results) == 1
|
||||
assert results[0].role.value == "assistant"
|
||||
assert results[0].finish_reason is not None
|
||||
assert results[0].finish_reason.value == "stop"
|
||||
assert "Hello!" in results[0].content
|
||||
async_mock_client.chat.complete_async.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_mistral_ai_chat_completion_get_chat_message_contents_failure():
|
||||
"""Test get_chat_message_contents should raise ServiceResponseException if Mistral call fails."""
|
||||
async_mock_client = MagicMock(spec=Mistral)
|
||||
async_mock_client.chat = MagicMock()
|
||||
async_mock_client.chat.complete_async = AsyncMock(side_effect=Exception("API error"))
|
||||
|
||||
chat_completion = MistralAIChatCompletion(
|
||||
ai_model_id="test-model",
|
||||
api_key="test_key",
|
||||
async_client=async_mock_client,
|
||||
)
|
||||
|
||||
chat_history = ChatHistory()
|
||||
settings = MistralAIChatPromptExecutionSettings()
|
||||
|
||||
with pytest.raises(ServiceResponseException) as exc:
|
||||
await chat_completion.get_chat_message_contents(chat_history, settings)
|
||||
assert "service failed to complete the prompt" in str(exc.value)
|
||||
|
||||
|
||||
async def test_mistral_ai_chat_completion_get_streaming_chat_message_contents_success():
|
||||
"""Test get_streaming_chat_message_contents when streaming successfully."""
|
||||
|
||||
# We'll yield multiple chunks to simulate streaming.
|
||||
mock_chunk1 = CompletionEvent(
|
||||
data=CompletionChunk(
|
||||
id="chunk1",
|
||||
created=1,
|
||||
model="test-model",
|
||||
choices=[
|
||||
CompletionResponseStreamChoice(
|
||||
index=0,
|
||||
delta=DeltaMessage(role="assistant", content="Hello "),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
mock_chunk2 = CompletionEvent(
|
||||
data=CompletionChunk(
|
||||
id="chunk1",
|
||||
created=1,
|
||||
model="test-model",
|
||||
choices=[
|
||||
CompletionResponseStreamChoice(
|
||||
index=0,
|
||||
delta=DeltaMessage(content="World!"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
async def mock_stream_async(**kwargs):
|
||||
yield mock_chunk1
|
||||
yield mock_chunk2
|
||||
|
||||
async_mock_client = MagicMock(spec=Mistral)
|
||||
async_mock_client.chat = MagicMock()
|
||||
async_mock_client.chat.stream_async = AsyncMock(return_value=mock_stream_async())
|
||||
|
||||
chat_completion = MistralAIChatCompletion(
|
||||
ai_model_id="test-model",
|
||||
api_key="test_key",
|
||||
async_client=async_mock_client,
|
||||
)
|
||||
|
||||
chat_history = ChatHistory()
|
||||
settings = MistralAIChatPromptExecutionSettings()
|
||||
|
||||
collected_chunks = []
|
||||
async for chunk_list in chat_completion.get_streaming_chat_message_contents(chat_history, settings):
|
||||
collected_chunks.append(chunk_list)
|
||||
|
||||
# We expect two sets of chunk_list yields.
|
||||
assert len(collected_chunks) == 2
|
||||
assert len(collected_chunks[0]) == 1
|
||||
assert len(collected_chunks[1]) == 1
|
||||
|
||||
# First chunk contains "Hello ", second chunk "World!".
|
||||
assert collected_chunks[0][0].items[0].text == "Hello "
|
||||
assert collected_chunks[1][0].items[0].text == "World!"
|
||||
|
||||
|
||||
async def test_mistral_ai_chat_completion_get_streaming_chat_message_contents_failure():
|
||||
"""Test get_streaming_chat_message_contents raising a ServiceResponseException on failure."""
|
||||
async_mock_client = MagicMock(spec=Mistral)
|
||||
async_mock_client.chat = MagicMock()
|
||||
async_mock_client.chat.stream_async = AsyncMock(side_effect=Exception("Streaming error"))
|
||||
|
||||
chat_completion = MistralAIChatCompletion(
|
||||
ai_model_id="test-model",
|
||||
api_key="test_key",
|
||||
async_client=async_mock_client,
|
||||
)
|
||||
|
||||
chat_history = ChatHistory()
|
||||
settings = MistralAIChatPromptExecutionSettings()
|
||||
|
||||
with pytest.raises(ServiceResponseException) as exc:
|
||||
async for _ in chat_completion.get_streaming_chat_message_contents(chat_history, settings):
|
||||
pass
|
||||
assert "service failed to complete the prompt" in str(exc.value)
|
||||
|
||||
|
||||
def test_mistral_ai_chat_completion_update_settings_from_function_call_configuration_mistral():
|
||||
"""Test update_settings_from_function_call_configuration_mistral sets tools etc."""
|
||||
|
||||
chat_completion = MistralAIChatCompletion(
|
||||
ai_model_id="test-model",
|
||||
api_key="test_key",
|
||||
)
|
||||
|
||||
# Create a mock settings object.
|
||||
settings = MistralAIChatPromptExecutionSettings()
|
||||
# Create a function choice config with some available functions.
|
||||
config = FunctionCallChoiceConfiguration()
|
||||
mock_func = MagicMock(
|
||||
spec=KernelFunction,
|
||||
)
|
||||
mock_func.name = "my_func"
|
||||
mock_func.description = "some desc"
|
||||
mock_func.fully_qualified_name = "mod.my_func"
|
||||
mock_func.parameters = []
|
||||
config.available_functions = [mock_func]
|
||||
|
||||
# Call the update_settings_from_function_call_configuration_mistral with type=ANY.
|
||||
chat_completion.update_settings_from_function_call_configuration_mistral(
|
||||
function_choice_configuration=config,
|
||||
settings=settings,
|
||||
type=FunctionChoiceType.AUTO,
|
||||
)
|
||||
|
||||
assert settings.tool_choice == FunctionChoiceType.AUTO.value
|
||||
assert settings.tools is not None
|
||||
assert len(settings.tools) == 1
|
||||
assert settings.tools[0]["function"]["name"] == "mod.my_func"
|
||||
|
||||
|
||||
def test_mistral_ai_chat_completion_reset_function_choice_settings():
|
||||
"""Test that _reset_function_choice_settings resets specific attributes."""
|
||||
chat_completion = MistralAIChatCompletion(
|
||||
ai_model_id="test-model",
|
||||
api_key="test_key",
|
||||
)
|
||||
settings = MistralAIChatPromptExecutionSettings(tool_choice="any", tools=[{"name": "func1"}])
|
||||
|
||||
chat_completion._reset_function_choice_settings(settings)
|
||||
assert settings.tool_choice is None
|
||||
assert settings.tools is None
|
||||
|
||||
|
||||
def test_mistral_ai_chat_completion_service_url():
|
||||
"""Test that service_url attempts to use _endpoint from the async_client."""
|
||||
async_mock_client = MagicMock(spec=Mistral)
|
||||
async_mock_client._endpoint = "mistral"
|
||||
|
||||
chat_completion = MistralAIChatCompletion(
|
||||
ai_model_id="test-model",
|
||||
api_key="test_key",
|
||||
async_client=async_mock_client,
|
||||
)
|
||||
|
||||
url = chat_completion.service_url()
|
||||
assert url == "mistral"
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from mistralai import Mistral
|
||||
from mistralai.models import EmbeddingResponse
|
||||
|
||||
from semantic_kernel.connectors.ai.mistral_ai.services.mistral_ai_text_embedding import MistralAITextEmbedding
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceResponseException
|
||||
|
||||
|
||||
def test_embedding_with_env_variables(mistralai_unit_test_env):
|
||||
text_embedding = MistralAITextEmbedding()
|
||||
assert text_embedding.ai_model_id == "test_embedding_model_id"
|
||||
assert text_embedding.async_client.sdk_configuration.security.api_key == "test_api_key"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_API_KEY", "MISTRALAI_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_embedding_with_constructor(mistralai_unit_test_env):
|
||||
text_embedding = MistralAITextEmbedding(
|
||||
api_key="overwrite-api-key",
|
||||
ai_model_id="overwrite-model",
|
||||
)
|
||||
assert text_embedding.ai_model_id == "overwrite-model"
|
||||
assert text_embedding.async_client.sdk_configuration.security.api_key == "overwrite-api-key"
|
||||
|
||||
|
||||
def test_embedding_with_client(mistralai_unit_test_env):
|
||||
client = MagicMock(spec=Mistral)
|
||||
text_embedding = MistralAITextEmbedding(async_client=client)
|
||||
assert text_embedding.async_client == client
|
||||
assert text_embedding.ai_model_id == "test_embedding_model_id"
|
||||
|
||||
|
||||
def test_embedding_with_api_key(mistralai_unit_test_env):
|
||||
text_embedding = MistralAITextEmbedding(api_key="overwrite-api-key")
|
||||
assert text_embedding.async_client.sdk_configuration.security.api_key == "overwrite-api-key"
|
||||
assert text_embedding.ai_model_id == "test_embedding_model_id"
|
||||
|
||||
|
||||
def test_embedding_with_model(mistralai_unit_test_env):
|
||||
text_embedding = MistralAITextEmbedding(ai_model_id="overwrite-model")
|
||||
assert text_embedding.ai_model_id == "overwrite-model"
|
||||
assert text_embedding.async_client.sdk_configuration.security.api_key == "test_api_key"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_embedding_with_model_without_env(mistralai_unit_test_env):
|
||||
text_embedding = MistralAITextEmbedding(ai_model_id="overwrite-model")
|
||||
assert text_embedding.ai_model_id == "overwrite-model"
|
||||
assert text_embedding.async_client.sdk_configuration.security.api_key == "test_api_key"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_embedding_missing_model(mistralai_unit_test_env):
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
MistralAITextEmbedding(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_API_KEY"]], indirect=True)
|
||||
def test_embedding_missing_api_key(mistralai_unit_test_env):
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
MistralAITextEmbedding(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_API_KEY", "MISTRALAI_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_embedding_missing_api_key_constructor(mistralai_unit_test_env):
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
MistralAITextEmbedding(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["MISTRALAI_API_KEY", "MISTRALAI_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_embedding_missing_model_constructor(mistralai_unit_test_env):
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
MistralAITextEmbedding(
|
||||
api_key="test_api_key",
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
async def test_embedding_generate_raw_embedding(mistralai_unit_test_env):
|
||||
mock_client = AsyncMock(spec=Mistral)
|
||||
mock_client.embeddings = AsyncMock()
|
||||
mock_embedding_response = MagicMock(spec=EmbeddingResponse, data=[MagicMock(embedding=[1, 2, 3, 4, 5])])
|
||||
mock_client.embeddings.create_async.return_value = mock_embedding_response
|
||||
text_embedding = MistralAITextEmbedding(async_client=mock_client)
|
||||
embedding = await text_embedding.generate_raw_embeddings(["test"])
|
||||
assert embedding == [[1, 2, 3, 4, 5]]
|
||||
|
||||
|
||||
async def test_embedding_generate_embedding(mistralai_unit_test_env):
|
||||
mock_client = AsyncMock(spec=Mistral)
|
||||
mock_client.embeddings = AsyncMock()
|
||||
mock_embedding_response = MagicMock(spec=EmbeddingResponse, data=[MagicMock(embedding=[1, 2, 3, 4, 5])])
|
||||
mock_client.embeddings.create_async.return_value = mock_embedding_response
|
||||
text_embedding = MistralAITextEmbedding(async_client=mock_client)
|
||||
embedding = await text_embedding.generate_embeddings(["test"])
|
||||
assert embedding.tolist() == [[1, 2, 3, 4, 5]]
|
||||
|
||||
|
||||
async def test_embedding_generate_embedding_exception(mistralai_unit_test_env):
|
||||
mock_client = AsyncMock(spec=Mistral)
|
||||
mock_client.embeddings = AsyncMock()
|
||||
mock_client.embeddings.create_async.side_effect = Exception("Test Exception")
|
||||
text_embedding = MistralAITextEmbedding(async_client=mock_client)
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await text_embedding.generate_embeddings(["test"])
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.ai.mistral_ai.prompt_execution_settings.mistral_ai_prompt_execution_settings import (
|
||||
MistralAIChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
|
||||
|
||||
def test_default_mistralai_chat_prompt_execution_settings():
|
||||
settings = MistralAIChatPromptExecutionSettings()
|
||||
assert settings.temperature is None
|
||||
assert settings.top_p is None
|
||||
assert settings.max_tokens is None
|
||||
assert settings.messages is None
|
||||
|
||||
|
||||
def test_custom_mistralai_chat_prompt_execution_settings():
|
||||
settings = MistralAIChatPromptExecutionSettings(
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
max_tokens=128,
|
||||
messages=[{"role": "system", "content": "Hello"}],
|
||||
)
|
||||
assert settings.temperature == 0.5
|
||||
assert settings.top_p == 0.5
|
||||
assert settings.max_tokens == 128
|
||||
assert settings.messages == [{"role": "system", "content": "Hello"}]
|
||||
|
||||
|
||||
def test_mistralai_chat_prompt_execution_settings_from_default_completion_config():
|
||||
settings = PromptExecutionSettings(service_id="test_service")
|
||||
chat_settings = MistralAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.temperature is None
|
||||
assert chat_settings.top_p is None
|
||||
assert chat_settings.max_tokens is None
|
||||
|
||||
|
||||
def test_mistral_chat_prompt_execution_settings_from_openai_prompt_execution_settings():
|
||||
chat_settings = MistralAIChatPromptExecutionSettings(service_id="test_service", temperature=1.0)
|
||||
new_settings = MistralAIChatPromptExecutionSettings(service_id="test_2", temperature=0.0)
|
||||
chat_settings.update_from_prompt_execution_settings(new_settings)
|
||||
assert chat_settings.service_id == "test_2"
|
||||
assert chat_settings.temperature == 0.0
|
||||
|
||||
|
||||
def test_mistral_chat_prompt_execution_settings_from_custom_completion_config():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
chat_settings = MistralAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_from_custom_completion_config_with_none():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
chat_settings = MistralAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_from_custom_completion_config_with_functions():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"tools": [{}],
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
chat_settings = MistralAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
|
||||
|
||||
def test_create_options():
|
||||
settings = MistralAIChatPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"tools": [{}],
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
options = settings.prepare_settings_dict()
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.5
|
||||
assert options["max_tokens"] == 128
|
||||
|
||||
|
||||
def test_create_options_with_function_choice_behavior():
|
||||
settings = MistralAIChatPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
function_choice_behavior="auto",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_tokens": 128,
|
||||
"tools": [{}],
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
assert settings.function_choice_behavior
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from semantic_kernel.connectors.ai.nvidia.prompt_execution_settings.nvidia_prompt_execution_settings import (
|
||||
NvidiaChatPromptExecutionSettings,
|
||||
NvidiaEmbeddingPromptExecutionSettings,
|
||||
NvidiaPromptExecutionSettings,
|
||||
)
|
||||
|
||||
|
||||
class TestNvidiaPromptExecutionSettings:
|
||||
"""Test cases for NvidiaPromptExecutionSettings."""
|
||||
|
||||
def test_init_with_defaults(self):
|
||||
"""Test initialization with default values."""
|
||||
settings = NvidiaPromptExecutionSettings()
|
||||
assert settings.format is None
|
||||
assert settings.options is None
|
||||
|
||||
def test_init_with_values(self):
|
||||
"""Test initialization with specific values."""
|
||||
settings = NvidiaPromptExecutionSettings(
|
||||
format="json",
|
||||
options={"key": "value"},
|
||||
)
|
||||
assert settings.format == "json"
|
||||
assert settings.options == {"key": "value"}
|
||||
|
||||
def test_validation_format_values(self):
|
||||
"""Test format validation values."""
|
||||
# Valid values
|
||||
settings = NvidiaPromptExecutionSettings(format="json")
|
||||
assert settings.format == "json"
|
||||
|
||||
|
||||
class TestNvidiaChatPromptExecutionSettings:
|
||||
"""Test cases for NvidiaChatPromptExecutionSettings."""
|
||||
|
||||
def test_init_with_defaults(self):
|
||||
"""Test initialization with default values."""
|
||||
settings = NvidiaChatPromptExecutionSettings()
|
||||
assert settings.messages is None
|
||||
assert settings.response_format is None
|
||||
|
||||
def test_response_format_with_pydantic_model(self):
|
||||
"""Test response_format with Pydantic model."""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
name: str
|
||||
value: int
|
||||
|
||||
settings = NvidiaChatPromptExecutionSettings(response_format=TestModel)
|
||||
|
||||
assert settings.response_format == TestModel
|
||||
|
||||
def test_response_format_with_dict(self):
|
||||
"""Test response_format with dictionary."""
|
||||
settings = NvidiaChatPromptExecutionSettings(response_format={"type": "json_object"})
|
||||
|
||||
assert settings.response_format == {"type": "json_object"}
|
||||
|
||||
|
||||
class TestNvidiaEmbeddingPromptExecutionSettings:
|
||||
"""Test cases for NvidiaEmbeddingPromptExecutionSettings."""
|
||||
|
||||
def test_init_with_defaults(self):
|
||||
"""Test initialization with default values."""
|
||||
settings = NvidiaEmbeddingPromptExecutionSettings()
|
||||
assert settings.input is None
|
||||
assert settings.encoding_format == "float"
|
||||
assert settings.input_type == "query"
|
||||
assert settings.truncate == "NONE"
|
||||
|
||||
def test_init_with_values(self):
|
||||
"""Test initialization with specific values."""
|
||||
settings = NvidiaEmbeddingPromptExecutionSettings(
|
||||
input=["hello", "world"],
|
||||
encoding_format="base64",
|
||||
input_type="passage",
|
||||
truncate="START",
|
||||
)
|
||||
|
||||
assert settings.input == ["hello", "world"]
|
||||
assert settings.encoding_format == "base64"
|
||||
assert settings.input_type == "passage"
|
||||
assert settings.truncate == "START"
|
||||
|
||||
def test_validation_encoding_format(self):
|
||||
"""Test encoding_format validation."""
|
||||
# Valid values
|
||||
settings = NvidiaEmbeddingPromptExecutionSettings(encoding_format="float")
|
||||
assert settings.encoding_format == "float"
|
||||
|
||||
settings = NvidiaEmbeddingPromptExecutionSettings(encoding_format="base64")
|
||||
assert settings.encoding_format == "base64"
|
||||
|
||||
# Invalid values
|
||||
with pytest.raises(ValidationError):
|
||||
NvidiaEmbeddingPromptExecutionSettings(encoding_format="invalid")
|
||||
@@ -0,0 +1,151 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from openai.resources.chat.completions import AsyncCompletions
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
from pydantic import BaseModel
|
||||
|
||||
from semantic_kernel.connectors.ai.nvidia import NvidiaChatCompletion
|
||||
from semantic_kernel.connectors.ai.nvidia.prompt_execution_settings.nvidia_prompt_execution_settings import (
|
||||
NvidiaChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.nvidia.services.nvidia_chat_completion import DEFAULT_NVIDIA_CHAT_MODEL
|
||||
from semantic_kernel.contents import ChatHistory
|
||||
from semantic_kernel.exceptions import ServiceInitializationError, ServiceResponseException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nvidia_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for NvidiaChatCompletion."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {"NVIDIA_API_KEY": "test_api_key", "NVIDIA_CHAT_MODEL_ID": "meta/llama-3.1-8b-instruct"}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
def _create_mock_chat_completion(content: str = "Hello!") -> ChatCompletion:
|
||||
"""Helper function to create a mock ChatCompletion response."""
|
||||
message = ChatCompletionMessage(role="assistant", content=content)
|
||||
choice = Choice(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=message,
|
||||
)
|
||||
usage = CompletionUsage(completion_tokens=20, prompt_tokens=10, total_tokens=30)
|
||||
return ChatCompletion(
|
||||
id="test-id",
|
||||
choices=[choice],
|
||||
created=1234567890,
|
||||
model="meta/llama-3.1-8b-instruct",
|
||||
object="chat.completion",
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
class TestNvidiaChatCompletion:
|
||||
"""Test cases for NvidiaChatCompletion."""
|
||||
|
||||
def test_init_with_defaults(self, nvidia_unit_test_env):
|
||||
"""Test initialization with default values."""
|
||||
service = NvidiaChatCompletion()
|
||||
assert service.ai_model_id == nvidia_unit_test_env["NVIDIA_CHAT_MODEL_ID"]
|
||||
|
||||
def test_get_prompt_execution_settings_class(self, nvidia_unit_test_env):
|
||||
"""Test getting the prompt execution settings class."""
|
||||
service = NvidiaChatCompletion()
|
||||
from semantic_kernel.connectors.ai.nvidia.prompt_execution_settings.nvidia_prompt_execution_settings import (
|
||||
NvidiaChatPromptExecutionSettings,
|
||||
)
|
||||
|
||||
assert service.get_prompt_execution_settings_class() == NvidiaChatPromptExecutionSettings
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["NVIDIA_API_KEY"]], indirect=True)
|
||||
def test_init_with_empty_api_key(self, nvidia_unit_test_env):
|
||||
"""Test initialization fails with empty API key."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
NvidiaChatCompletion()
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["NVIDIA_CHAT_MODEL_ID"]], indirect=True)
|
||||
def test_init_with_empty_model_id(self, nvidia_unit_test_env):
|
||||
"""Test initialization with empty model ID uses default."""
|
||||
service = NvidiaChatCompletion()
|
||||
assert service.ai_model_id == DEFAULT_NVIDIA_CHAT_MODEL
|
||||
|
||||
def test_init_with_custom_model_id(self, nvidia_unit_test_env):
|
||||
"""Test initialization with custom model ID."""
|
||||
custom_model = "custom/nvidia-model"
|
||||
service = NvidiaChatCompletion(ai_model_id=custom_model)
|
||||
assert service.ai_model_id == custom_model
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_get_chat_message_contents(self, mock_create, nvidia_unit_test_env):
|
||||
"""Test basic chat completion."""
|
||||
mock_create.return_value = _create_mock_chat_completion("Hello!")
|
||||
|
||||
service = NvidiaChatCompletion()
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("Hello")
|
||||
settings = NvidiaChatPromptExecutionSettings()
|
||||
|
||||
result = await service.get_chat_message_contents(chat_history, settings)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].content == "Hello!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_structured_output_with_pydantic_model(self, mock_create, nvidia_unit_test_env):
|
||||
"""Test structured output with Pydantic model."""
|
||||
|
||||
# Define test model
|
||||
class TestModel(BaseModel):
|
||||
name: str
|
||||
value: int
|
||||
|
||||
mock_create.return_value = _create_mock_chat_completion('{"name": "test", "value": 42}')
|
||||
|
||||
service = NvidiaChatCompletion()
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("Give me structured data")
|
||||
settings = NvidiaChatPromptExecutionSettings()
|
||||
settings.response_format = TestModel
|
||||
|
||||
await service.get_chat_message_contents(chat_history, settings)
|
||||
|
||||
# Verify nvext was passed
|
||||
call_args = mock_create.call_args[1]
|
||||
assert "extra_body" in call_args
|
||||
assert "nvext" in call_args["extra_body"]
|
||||
assert "guided_json" in call_args["extra_body"]["nvext"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_error_handling(self, mock_create, nvidia_unit_test_env):
|
||||
"""Test error handling."""
|
||||
mock_create.side_effect = Exception("API Error")
|
||||
|
||||
service = NvidiaChatCompletion()
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("Hello")
|
||||
settings = NvidiaChatPromptExecutionSettings()
|
||||
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await service.get_chat_message_contents(chat_history, settings)
|
||||
@@ -0,0 +1,152 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from semantic_kernel.connectors.ai.nvidia.prompt_execution_settings.nvidia_prompt_execution_settings import (
|
||||
NvidiaChatPromptExecutionSettings,
|
||||
NvidiaEmbeddingPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.nvidia.services.nvidia_handler import NvidiaHandler
|
||||
from semantic_kernel.connectors.ai.nvidia.services.nvidia_model_types import NvidiaModelTypes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_client():
|
||||
"""Create a mock OpenAI client."""
|
||||
return AsyncMock(spec=AsyncOpenAI)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nvidia_handler(mock_openai_client):
|
||||
"""Create a NvidiaHandler instance with mocked client."""
|
||||
return NvidiaHandler(
|
||||
client=mock_openai_client,
|
||||
ai_model_type=NvidiaModelTypes.CHAT,
|
||||
ai_model_id="test-model",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
|
||||
class TestNvidiaHandler:
|
||||
"""Test cases for NvidiaHandler."""
|
||||
|
||||
def test_init(self, mock_openai_client):
|
||||
"""Test initialization."""
|
||||
handler = NvidiaHandler(
|
||||
client=mock_openai_client,
|
||||
ai_model_type=NvidiaModelTypes.CHAT,
|
||||
)
|
||||
|
||||
assert handler.client == mock_openai_client
|
||||
assert handler.ai_model_type == NvidiaModelTypes.CHAT
|
||||
assert handler.MODEL_PROVIDER_NAME == "nvidia"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_chat_completion_request(self, nvidia_handler, mock_openai_client):
|
||||
"""Test sending chat completion request."""
|
||||
# Mock the response
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [
|
||||
MagicMock(
|
||||
message=MagicMock(role="assistant", content="Hello!"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
]
|
||||
mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=20, total_tokens=30)
|
||||
mock_openai_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Create settings
|
||||
settings = NvidiaChatPromptExecutionSettings(
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
# Test the method
|
||||
result = await nvidia_handler._send_chat_completion_request(settings)
|
||||
assert result == mock_response
|
||||
|
||||
# Verify usage was stored
|
||||
assert nvidia_handler.prompt_tokens == 10
|
||||
assert nvidia_handler.completion_tokens == 20
|
||||
assert nvidia_handler.total_tokens == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_chat_completion_request_with_nvext(self, nvidia_handler, mock_openai_client):
|
||||
"""Test sending chat completion request with nvext parameter."""
|
||||
# Mock the response
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [
|
||||
MagicMock(
|
||||
message=MagicMock(role="assistant", content='{"result": "success"}'),
|
||||
finish_reason="stop",
|
||||
)
|
||||
]
|
||||
mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=20, total_tokens=30)
|
||||
mock_openai_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Create settings with nvext
|
||||
settings = NvidiaChatPromptExecutionSettings(
|
||||
messages=[{"role": "user", "content": "Give me JSON"}],
|
||||
model="test-model",
|
||||
extra_body={"nvext": {"guided_json": {"type": "object"}}},
|
||||
)
|
||||
|
||||
# Test the method
|
||||
result = await nvidia_handler._send_chat_completion_request(settings)
|
||||
assert result == mock_response
|
||||
|
||||
# Verify the client was called with nvext in extra_body
|
||||
call_args = mock_openai_client.chat.completions.create.call_args[1]
|
||||
assert "extra_body" in call_args
|
||||
assert "nvext" in call_args["extra_body"]
|
||||
assert call_args["extra_body"]["nvext"] == {"guided_json": {"type": "object"}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_embedding_request(self, mock_openai_client):
|
||||
"""Test sending embedding request."""
|
||||
handler = NvidiaHandler(
|
||||
client=mock_openai_client,
|
||||
ai_model_type=NvidiaModelTypes.EMBEDDING,
|
||||
ai_model_id="test-model",
|
||||
)
|
||||
|
||||
# Mock the response
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [
|
||||
MagicMock(embedding=[0.1, 0.2, 0.3]),
|
||||
MagicMock(embedding=[0.4, 0.5, 0.6]),
|
||||
]
|
||||
mock_response.usage = MagicMock(prompt_tokens=10, total_tokens=10)
|
||||
mock_openai_client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Create settings
|
||||
settings = NvidiaEmbeddingPromptExecutionSettings(
|
||||
input=["hello", "world"],
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
# Test the method
|
||||
result = await handler._send_embedding_request(settings)
|
||||
assert result == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_request_unsupported_model_type(self, mock_openai_client):
|
||||
"""Test send_request with unsupported model type."""
|
||||
# Create a handler with invalid model type by bypassing validation
|
||||
handler = NvidiaHandler(
|
||||
client=mock_openai_client,
|
||||
ai_model_type=NvidiaModelTypes.CHAT,
|
||||
)
|
||||
# Manually set the attribute to bypass Pydantic validation
|
||||
object.__setattr__(handler, "ai_model_type", "UNSUPPORTED")
|
||||
|
||||
settings = NvidiaChatPromptExecutionSettings(
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
with pytest.raises(NotImplementedError, match="Model type UNSUPPORTED is not supported"):
|
||||
await handler._send_request(settings)
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncClient
|
||||
from openai.resources.embeddings import AsyncEmbeddings
|
||||
|
||||
from semantic_kernel.connectors.ai.nvidia.prompt_execution_settings.nvidia_prompt_execution_settings import (
|
||||
NvidiaEmbeddingPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.nvidia.services.nvidia_text_embedding import NvidiaTextEmbedding
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceResponseException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nvidia_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for NvidiaTextEmbedding."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {"NVIDIA_API_KEY": "test_api_key", "NVIDIA_EMBEDDING_MODEL_ID": "test_embedding_model_id"}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
def test_init(nvidia_unit_test_env):
|
||||
nvidia_text_embedding = NvidiaTextEmbedding()
|
||||
|
||||
assert nvidia_text_embedding.client is not None
|
||||
assert isinstance(nvidia_text_embedding.client, AsyncClient)
|
||||
assert nvidia_text_embedding.ai_model_id == nvidia_unit_test_env["NVIDIA_EMBEDDING_MODEL_ID"]
|
||||
|
||||
assert nvidia_text_embedding.get_prompt_execution_settings_class() == NvidiaEmbeddingPromptExecutionSettings
|
||||
|
||||
|
||||
def test_init_validation_fail() -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
NvidiaTextEmbedding(api_key="34523", ai_model_id={"test": "dict"})
|
||||
|
||||
|
||||
def test_init_to_from_dict(nvidia_unit_test_env):
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": nvidia_unit_test_env["NVIDIA_EMBEDDING_MODEL_ID"],
|
||||
"api_key": nvidia_unit_test_env["NVIDIA_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
text_embedding = NvidiaTextEmbedding.from_dict(settings)
|
||||
dumped_settings = text_embedding.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == settings["ai_model_id"]
|
||||
assert dumped_settings["api_key"] == settings["api_key"]
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock)
|
||||
async def test_embedding_calls_with_parameters(mock_create, nvidia_unit_test_env) -> None:
|
||||
ai_model_id = "NV-Embed-QA"
|
||||
texts = ["hello world", "goodbye world"]
|
||||
embedding_dimensions = 1536
|
||||
|
||||
nvidia_text_embedding = NvidiaTextEmbedding(
|
||||
ai_model_id=ai_model_id,
|
||||
)
|
||||
|
||||
await nvidia_text_embedding.generate_embeddings(texts, dimensions=embedding_dimensions)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=ai_model_id,
|
||||
dimensions=embedding_dimensions,
|
||||
encoding_format="float",
|
||||
extra_body={"input_type": "query", "truncate": "NONE"},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock)
|
||||
async def test_embedding_calls_with_settings(mock_create, nvidia_unit_test_env) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
texts = ["hello world", "goodbye world"]
|
||||
settings = NvidiaEmbeddingPromptExecutionSettings(service_id="default")
|
||||
nvidia_text_embedding = NvidiaTextEmbedding(service_id="default", ai_model_id=ai_model_id)
|
||||
|
||||
await nvidia_text_embedding.generate_embeddings(texts, settings=settings)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=ai_model_id,
|
||||
encoding_format="float",
|
||||
extra_body={"input_type": "query", "truncate": "NONE"},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock, side_effect=Exception)
|
||||
async def test_embedding_fail(mock_create, nvidia_unit_test_env) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
texts = ["hello world", "goodbye world"]
|
||||
|
||||
nvidia_text_embedding = NvidiaTextEmbedding(
|
||||
ai_model_id=ai_model_id,
|
||||
)
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await nvidia_text_embedding.generate_embeddings(texts)
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock)
|
||||
async def test_embedding_pes(mock_create, nvidia_unit_test_env) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
texts = ["hello world", "goodbye world"]
|
||||
|
||||
pes = PromptExecutionSettings(service_id="x", ai_model_id=ai_model_id)
|
||||
|
||||
nvidia_text_embedding = NvidiaTextEmbedding(ai_model_id=ai_model_id)
|
||||
|
||||
await nvidia_text_embedding.generate_raw_embeddings(texts, pes)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=ai_model_id,
|
||||
encoding_format="float",
|
||||
extra_body={"input_type": "query", "truncate": "NONE"},
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from semantic_kernel.connectors.ai.nvidia.settings.nvidia_settings import NvidiaSettings
|
||||
|
||||
|
||||
class TestNvidiaSettings:
|
||||
"""Test cases for NvidiaSettings."""
|
||||
|
||||
def test_init_with_defaults(self):
|
||||
"""Test initialization with default values."""
|
||||
settings = NvidiaSettings()
|
||||
assert settings.api_key is None
|
||||
assert settings.base_url == "https://integrate.api.nvidia.com/v1"
|
||||
assert settings.embedding_model_id is None
|
||||
assert settings.chat_model_id is None
|
||||
|
||||
def test_init_with_values(self):
|
||||
"""Test initialization with specific values."""
|
||||
settings = NvidiaSettings(
|
||||
api_key="test-api-key",
|
||||
base_url="https://custom.nvidia.com/v1",
|
||||
embedding_model_id="test-embedding-model",
|
||||
chat_model_id="test-chat-model",
|
||||
)
|
||||
|
||||
assert settings.api_key.get_secret_value() == "test-api-key"
|
||||
assert settings.base_url == "https://custom.nvidia.com/v1"
|
||||
assert settings.embedding_model_id == "test-embedding-model"
|
||||
assert settings.chat_model_id == "test-chat-model"
|
||||
|
||||
def test_env_prefix(self):
|
||||
"""Test environment variable prefix."""
|
||||
assert NvidiaSettings.env_prefix == "NVIDIA_"
|
||||
|
||||
def test_api_key_secret_str(self):
|
||||
"""Test that api_key is properly handled as SecretStr."""
|
||||
settings = NvidiaSettings(api_key="secret-key")
|
||||
|
||||
# Should be SecretStr type
|
||||
assert hasattr(settings.api_key, "get_secret_value")
|
||||
assert settings.api_key.get_secret_value() == "secret-key"
|
||||
|
||||
# Should not expose the secret in string representation
|
||||
str_repr = str(settings)
|
||||
assert "secret-key" not in str_repr
|
||||
|
||||
def test_environment_variables(self, monkeypatch):
|
||||
"""Test that environment variables override defaults."""
|
||||
monkeypatch.setenv("NVIDIA_API_KEY", "env-key")
|
||||
monkeypatch.setenv("NVIDIA_CHAT_MODEL_ID", "env-chat")
|
||||
|
||||
settings = NvidiaSettings()
|
||||
|
||||
assert settings.api_key.get_secret_value() == "env-key"
|
||||
assert settings.chat_model_id == "env-chat"
|
||||
@@ -0,0 +1,84 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from ollama import AsyncClient
|
||||
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def model_id() -> str:
|
||||
return "test_model_id"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def service_id() -> str:
|
||||
return "test_service_id"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def host() -> str:
|
||||
return "http://localhost:5000"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def custom_client() -> AsyncClient:
|
||||
return AsyncClient("http://localhost:5001")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def chat_history() -> ChatHistory:
|
||||
chat_history = ChatHistory()
|
||||
chat_history.add_user_message("test_prompt")
|
||||
return chat_history
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def prompt() -> str:
|
||||
return "test_prompt"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def default_options() -> dict:
|
||||
return {"test": "test"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ollama_unit_test_env(monkeypatch, host, exclude_list):
|
||||
"""Fixture to set environment variables for OllamaSettings."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
env_vars = {
|
||||
"OLLAMA_CHAT_MODEL_ID": "test_chat_model_id",
|
||||
"OLLAMA_TEXT_MODEL_ID": "test_text_model_id",
|
||||
"OLLAMA_EMBEDDING_MODEL_ID": "test_embedding_model_id",
|
||||
"OLLAMA_HOST": host,
|
||||
}
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_streaming_text_response() -> AsyncIterator:
|
||||
streaming_text_response = MagicMock(spec=AsyncGenerator)
|
||||
streaming_text_response.__aiter__.return_value = [{"response": "test_response"}]
|
||||
|
||||
return streaming_text_response
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_streaming_chat_response() -> AsyncIterator:
|
||||
streaming_chat_response = MagicMock(spec=AsyncGenerator)
|
||||
streaming_chat_response.__aiter__.return_value = [{"message": {"content": "test_response"}}]
|
||||
|
||||
return streaming_chat_response
|
||||
@@ -0,0 +1,453 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from ollama import AsyncClient
|
||||
|
||||
import semantic_kernel.connectors.ai.ollama.services.ollama_chat_completion as occ_module
|
||||
from semantic_kernel.connectors.ai.completion_usage import CompletionUsage
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.connectors.ai.ollama.ollama_prompt_execution_settings import OllamaChatPromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.ollama.services.ollama_chat_completion import OllamaChatCompletion
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
|
||||
from semantic_kernel.exceptions.service_exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
ServiceInvalidResponseError,
|
||||
)
|
||||
|
||||
|
||||
def test_settings(model_id):
|
||||
"""Test that the settings class is correct."""
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id)
|
||||
settings = ollama.get_prompt_execution_settings_class()
|
||||
assert settings == OllamaChatPromptExecutionSettings
|
||||
|
||||
|
||||
def test_init_empty_service_id(model_id):
|
||||
"""Test that the service initializes correctly with an empty service id."""
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id)
|
||||
assert ollama.service_id == model_id
|
||||
|
||||
|
||||
def test_init_empty_string_ai_model_id():
|
||||
"""Test that the service initializes with a error if there is no ai_model_id."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
_ = OllamaChatCompletion(ai_model_id="")
|
||||
|
||||
|
||||
def test_custom_client(model_id, custom_client):
|
||||
"""Test that the service initializes correctly with a custom client."""
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id, client=custom_client)
|
||||
assert ollama.client == custom_client
|
||||
|
||||
|
||||
def test_invalid_ollama_settings():
|
||||
"""Test that the service initializes incorrectly with invalid settings."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
_ = OllamaChatCompletion(ai_model_id=123)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OLLAMA_CHAT_MODEL_ID"]], indirect=True)
|
||||
def test_init_empty_model_id_in_env(ollama_unit_test_env):
|
||||
"""Test that the service initializes incorrectly with an empty model id."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
_ = OllamaChatCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
def test_function_choice_settings(ollama_unit_test_env):
|
||||
"""Test that REQUIRED and NONE function choice settings are unsupported."""
|
||||
ollama = OllamaChatCompletion()
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
ollama._verify_function_choice_settings(
|
||||
OllamaChatPromptExecutionSettings(function_choice_behavior=FunctionChoiceBehavior.Required())
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
ollama._verify_function_choice_settings(
|
||||
OllamaChatPromptExecutionSettings(function_choice_behavior=FunctionChoiceBehavior.NoneInvoke())
|
||||
)
|
||||
|
||||
|
||||
def test_service_url(ollama_unit_test_env):
|
||||
"""Test that the service URL is correct."""
|
||||
ollama = OllamaChatCompletion()
|
||||
assert ollama.service_url() == ollama_unit_test_env["OLLAMA_HOST"]
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.__init__", return_value=None) # mock_client
|
||||
@patch("ollama.AsyncClient.chat") # mock_chat_client
|
||||
async def test_custom_host(
|
||||
mock_chat_client,
|
||||
mock_client,
|
||||
model_id,
|
||||
service_id,
|
||||
host,
|
||||
chat_history,
|
||||
prompt,
|
||||
default_options,
|
||||
):
|
||||
"""Test that the service initializes and generates content correctly with a custom host."""
|
||||
mock_chat_client.return_value = {"message": {"content": "test_response"}}
|
||||
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id, host=host)
|
||||
|
||||
chat_responses = await ollama.get_chat_message_contents(
|
||||
chat_history,
|
||||
OllamaChatPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
)
|
||||
|
||||
# Check that the client was initialized once with the correct host
|
||||
assert mock_client.call_count == 1
|
||||
mock_client.assert_called_with(host=host)
|
||||
# Check that the chat client was called once and the responses are correct
|
||||
assert mock_chat_client.call_count == 1
|
||||
assert len(chat_responses) == 1
|
||||
assert chat_responses[0].content == "test_response"
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.__init__", return_value=None) # mock_client
|
||||
@patch("ollama.AsyncClient.chat") # mock_chat_client
|
||||
async def test_custom_host_streaming(
|
||||
mock_chat_client,
|
||||
mock_client,
|
||||
mock_streaming_chat_response,
|
||||
model_id,
|
||||
service_id,
|
||||
host,
|
||||
chat_history,
|
||||
prompt,
|
||||
default_options,
|
||||
):
|
||||
"""Test that the service initializes and generates streaming content correctly with a custom host."""
|
||||
mock_chat_client.return_value = mock_streaming_chat_response
|
||||
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id, host=host)
|
||||
|
||||
async for messages in ollama.get_streaming_chat_message_contents(
|
||||
chat_history,
|
||||
OllamaChatPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
):
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == "assistant"
|
||||
assert messages[0].content == "test_response"
|
||||
|
||||
# Check that the client was initialized once with the correct host
|
||||
assert mock_client.call_count == 1
|
||||
mock_client.assert_called_with(host=host)
|
||||
# Check that the chat client was called once
|
||||
assert mock_chat_client.call_count == 1
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.chat")
|
||||
async def test_chat_completion(mock_chat_client, model_id, service_id, chat_history, default_options):
|
||||
"""Test that the chat completion service completes correctly."""
|
||||
mock_chat_client.return_value = {"message": {"content": "test_response"}}
|
||||
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id)
|
||||
response = await ollama.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=OllamaChatPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
)
|
||||
|
||||
assert len(response) == 1
|
||||
assert response[0].content == "test_response"
|
||||
mock_chat_client.assert_called_once_with(
|
||||
model=model_id,
|
||||
messages=ollama._prepare_chat_history_for_request(chat_history),
|
||||
options=default_options,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.chat")
|
||||
async def test_chat_completion_wrong_return_type(
|
||||
mock_chat_client,
|
||||
mock_streaming_chat_response,
|
||||
model_id,
|
||||
service_id,
|
||||
chat_history,
|
||||
default_options,
|
||||
):
|
||||
"""Test that the chat completion service fails when the return type is incorrect."""
|
||||
mock_chat_client.return_value = mock_streaming_chat_response # should not be a streaming response
|
||||
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id)
|
||||
with pytest.raises(ServiceInvalidResponseError):
|
||||
await ollama.get_chat_message_contents(
|
||||
chat_history,
|
||||
OllamaChatPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.chat")
|
||||
async def test_streaming_chat_completion(
|
||||
mock_chat_client,
|
||||
mock_streaming_chat_response,
|
||||
model_id,
|
||||
service_id,
|
||||
chat_history,
|
||||
default_options,
|
||||
):
|
||||
"""Test that the streaming chat completion service completes correctly."""
|
||||
mock_chat_client.return_value = mock_streaming_chat_response
|
||||
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id)
|
||||
response = ollama.get_streaming_chat_message_contents(
|
||||
chat_history,
|
||||
OllamaChatPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
)
|
||||
|
||||
responses = []
|
||||
async for line in response:
|
||||
if line:
|
||||
assert line[0].content == "test_response"
|
||||
responses.append(line[0].content)
|
||||
assert len(responses) == 1
|
||||
|
||||
mock_chat_client.assert_called_once_with(
|
||||
model=model_id,
|
||||
messages=ollama._prepare_chat_history_for_request(chat_history),
|
||||
options=default_options,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.chat")
|
||||
async def test_streaming_chat_completion_wrong_return_type(
|
||||
mock_chat_client,
|
||||
model_id,
|
||||
service_id,
|
||||
chat_history,
|
||||
default_options,
|
||||
):
|
||||
"""Test that the chat completion streaming service fails when the return type is incorrect."""
|
||||
mock_chat_client.return_value = {"message": {"content": "test_response"}} # should not be a non-streaming response
|
||||
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id)
|
||||
with pytest.raises(ServiceInvalidResponseError):
|
||||
async for _ in ollama.get_streaming_chat_message_contents(
|
||||
chat_history,
|
||||
OllamaChatPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_ollama_chat_completion():
|
||||
async_client_mock = AsyncMock(spec=AsyncClient)
|
||||
async_client_mock.chat = AsyncMock()
|
||||
ollama_chat_completion = OllamaChatCompletion(
|
||||
service_id="test_service", ai_model_id="test_model_id", client=async_client_mock
|
||||
)
|
||||
return ollama_chat_completion, async_client_mock
|
||||
|
||||
|
||||
async def test_service_url_new(setup_ollama_chat_completion):
|
||||
ollama_chat_completion, async_client_mock = setup_ollama_chat_completion
|
||||
# Mock the client's internal structure
|
||||
async_client_mock._client = AsyncMock(spec=httpx.AsyncClient)
|
||||
async_client_mock._client.base_url = "http://mocked_base_url"
|
||||
|
||||
service_url = ollama_chat_completion.service_url()
|
||||
assert service_url == "http://mocked_base_url"
|
||||
|
||||
|
||||
async def test_prepare_chat_history_for_request(setup_ollama_chat_completion):
|
||||
ollama_chat_completion, _ = setup_ollama_chat_completion
|
||||
chat_history = MagicMock(spec=ChatHistory)
|
||||
chat_history.messages = []
|
||||
|
||||
prepared_history = ollama_chat_completion._prepare_chat_history_for_request(chat_history)
|
||||
assert prepared_history == []
|
||||
|
||||
|
||||
async def test_service_url_with_httpx_client(model_id: str) -> None:
|
||||
"""
|
||||
Test that service_url returns the base_url of the underlying httpx.AsyncClient.
|
||||
"""
|
||||
# Initialize an AsyncClient and manually set its _client attribute to an httpx.AsyncClient
|
||||
client = AsyncClient(host="unused")
|
||||
base = httpx.AsyncClient(base_url="http://example.com:8000")
|
||||
client._client = base # simulate underlying httpx client
|
||||
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id, client=client)
|
||||
# service_url should reflect the base_url of the httpx client
|
||||
assert ollama.service_url() == "http://example.com:8000"
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.chat", new_callable=AsyncMock)
|
||||
async def test_chat_response_branch(
|
||||
mock_chat: AsyncMock,
|
||||
model_id: str,
|
||||
service_id: str,
|
||||
default_options: dict,
|
||||
chat_history,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""
|
||||
Test get_chat_message_contents when AsyncClient.chat returns a ChatResponse instance.
|
||||
"""
|
||||
|
||||
class DummyFunction:
|
||||
def __init__(self, name, arguments):
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
|
||||
class DummyToolCall:
|
||||
def __init__(self, function):
|
||||
self.function = function
|
||||
|
||||
class DummyMessage:
|
||||
def __init__(self, content: str, tool_calls=None) -> None:
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls or []
|
||||
|
||||
class DummyChatResponse:
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
model: str,
|
||||
prompt_eval_count: int,
|
||||
eval_count: int,
|
||||
tool_calls=None,
|
||||
) -> None:
|
||||
function_calls = [
|
||||
DummyToolCall(DummyFunction(tc["function"]["name"], tc["function"]["arguments"])) for tc in tool_calls
|
||||
]
|
||||
self.message = DummyMessage(content, function_calls)
|
||||
self.model = model
|
||||
self.prompt_eval_count = prompt_eval_count
|
||||
self.eval_count = eval_count
|
||||
|
||||
# Monkeypatch the ChatResponse type in the module so isinstance works
|
||||
monkeypatch.setattr(occ_module, "ChatResponse", DummyChatResponse)
|
||||
|
||||
# Prepare a dummy ChatResponse return value
|
||||
dummy_resp = DummyChatResponse(
|
||||
content="resp_text",
|
||||
model="mdl",
|
||||
prompt_eval_count=2,
|
||||
eval_count=3,
|
||||
tool_calls=[{"function": {"name": "fn", "arguments": {"x": 1}}}],
|
||||
)
|
||||
mock_chat.return_value = dummy_resp
|
||||
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id)
|
||||
settings = OllamaChatPromptExecutionSettings(service_id=service_id, options=default_options)
|
||||
|
||||
results = await ollama.get_chat_message_contents(chat_history, settings)
|
||||
# Only one response expected
|
||||
assert len(results) == 1
|
||||
msg = results[0]
|
||||
# Assert it's a ChatMessageContent
|
||||
assert isinstance(msg, ChatMessageContent)
|
||||
# The content property should return the response text
|
||||
assert msg.content == "resp_text"
|
||||
|
||||
# The second item should be a FunctionCallContent
|
||||
func_item = msg.items[1]
|
||||
assert isinstance(func_item, FunctionCallContent)
|
||||
# Validate function call details
|
||||
assert func_item.name == "fn"
|
||||
assert func_item.arguments == {"x": 1}
|
||||
|
||||
# Check metadata
|
||||
assert "model" in msg.metadata and msg.metadata["model"] == "mdl"
|
||||
# Access usage directly, key should exist
|
||||
usage = msg.metadata["usage"]
|
||||
assert isinstance(usage, CompletionUsage)
|
||||
assert usage.prompt_tokens == 2 and usage.completion_tokens == 3
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.chat", new_callable=AsyncMock)
|
||||
async def test_streaming_chat_response_branch(
|
||||
mock_chat: AsyncMock,
|
||||
model_id: str,
|
||||
service_id: str,
|
||||
default_options: dict,
|
||||
chat_history,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""
|
||||
Test get_streaming_chat_message_contents when AsyncClient.chat yields ChatResponse instances.
|
||||
"""
|
||||
|
||||
class DummyFunction:
|
||||
def __init__(self, name, arguments):
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
|
||||
class DummyToolCall:
|
||||
def __init__(self, function):
|
||||
self.function = function
|
||||
|
||||
class DummyMessage:
|
||||
def __init__(self, content: str, tool_calls=None) -> None:
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls or []
|
||||
|
||||
class DummyChatResponse:
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
model: str,
|
||||
prompt_eval_count: int,
|
||||
eval_count: int,
|
||||
tool_calls=None,
|
||||
) -> None:
|
||||
function_calls = [
|
||||
DummyToolCall(DummyFunction(tc["function"]["name"], tc["function"]["arguments"])) for tc in tool_calls
|
||||
]
|
||||
self.message = DummyMessage(content, function_calls)
|
||||
self.model = model
|
||||
self.prompt_eval_count = prompt_eval_count
|
||||
self.eval_count = eval_count
|
||||
|
||||
# Monkeypatch ChatResponse type
|
||||
monkeypatch.setattr(occ_module, "ChatResponse", DummyChatResponse)
|
||||
|
||||
# Prepare an async generator yielding DummyChatResponse
|
||||
async def fake_stream() -> AsyncGenerator[DummyChatResponse, None]:
|
||||
yield DummyChatResponse(
|
||||
content="stream_text",
|
||||
model="m2",
|
||||
prompt_eval_count=1,
|
||||
eval_count=1,
|
||||
tool_calls=[{"function": {"name": "f2", "arguments": {}}}],
|
||||
)
|
||||
|
||||
mock_chat.return_value = fake_stream()
|
||||
|
||||
ollama = OllamaChatCompletion(ai_model_id=model_id)
|
||||
settings = OllamaChatPromptExecutionSettings(service_id=service_id, options=default_options)
|
||||
|
||||
collected = []
|
||||
# Iterate over streamed batches
|
||||
async for batch in ollama.get_streaming_chat_message_contents(chat_history, settings):
|
||||
# We expect a list with a single StreamingChatMessageContent
|
||||
assert len(batch) == 1
|
||||
sc = batch[0]
|
||||
assert isinstance(sc, StreamingChatMessageContent)
|
||||
|
||||
# First item should be text content
|
||||
text_item = sc.items[0]
|
||||
assert isinstance(text_item, StreamingTextContent)
|
||||
assert text_item.text == "stream_text"
|
||||
|
||||
# Next item should be a FunctionCallContent
|
||||
func_item = sc.items[1]
|
||||
assert isinstance(func_item, FunctionCallContent)
|
||||
assert func_item.name == "f2"
|
||||
|
||||
collected.append(sc)
|
||||
|
||||
# Only one batch should be collected
|
||||
assert len(collected) == 1
|
||||
@@ -0,0 +1,178 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.ollama.ollama_prompt_execution_settings import OllamaTextPromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.ollama.services.ollama_text_completion import OllamaTextCompletion
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceInvalidResponseError
|
||||
|
||||
|
||||
def test_settings(model_id):
|
||||
"""Test that the settings class is correct"""
|
||||
ollama = OllamaTextCompletion(ai_model_id=model_id)
|
||||
settings = ollama.get_prompt_execution_settings_class()
|
||||
assert settings == OllamaTextPromptExecutionSettings
|
||||
|
||||
|
||||
def test_init_empty_service_id(model_id):
|
||||
"""Test that the service initializes correctly with an empty service_id"""
|
||||
ollama = OllamaTextCompletion(ai_model_id=model_id)
|
||||
assert ollama.service_id == model_id
|
||||
|
||||
|
||||
def test_custom_client(model_id, custom_client):
|
||||
"""Test that the service initializes correctly with a custom client."""
|
||||
ollama = OllamaTextCompletion(ai_model_id=model_id, client=custom_client)
|
||||
assert ollama.client == custom_client
|
||||
|
||||
|
||||
def test_invalid_ollama_settings():
|
||||
"""Test that the service initializes incorrectly with invalid settings."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
_ = OllamaTextCompletion(ai_model_id=123)
|
||||
|
||||
|
||||
def test_service_url(ollama_unit_test_env):
|
||||
"""Test that the service URL is correct."""
|
||||
ollama = OllamaTextCompletion()
|
||||
assert ollama.service_url() == ollama_unit_test_env["OLLAMA_HOST"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OLLAMA_TEXT_MODEL_ID"]], indirect=True)
|
||||
def test_init_empty_model_id(ollama_unit_test_env):
|
||||
"""Test that the service initializes incorrectly with an empty model_id"""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
_ = OllamaTextCompletion(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.__init__", return_value=None) # mock_client
|
||||
@patch("ollama.AsyncClient.generate") # mock_completion_client
|
||||
async def test_custom_host(
|
||||
mock_completion_client, mock_client, model_id, service_id, host, chat_history, default_options
|
||||
):
|
||||
"""Test that the service initializes and generates content correctly with a custom host."""
|
||||
mock_completion_client.return_value = {"response": "test_response"}
|
||||
|
||||
ollama = OllamaTextCompletion(ai_model_id=model_id, host=host)
|
||||
_ = await ollama.get_text_contents(
|
||||
chat_history,
|
||||
OllamaTextPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
)
|
||||
|
||||
mock_client.assert_called_once_with(host=host)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.__init__", return_value=None) # mock_client
|
||||
@patch("ollama.AsyncClient.generate") # mock_completion_client
|
||||
async def test_custom_host_streaming(
|
||||
mock_completion_client, mock_client, model_id, service_id, host, chat_history, default_options
|
||||
):
|
||||
"""Test that the service initializes and generates streaming content correctly with a custom host."""
|
||||
# Create a proper async iterator mock for streaming
|
||||
streaming_response = MagicMock(spec=AsyncGenerator)
|
||||
streaming_response.__aiter__.return_value = [{"response": "test_response"}]
|
||||
mock_completion_client.return_value = streaming_response
|
||||
|
||||
ollama = OllamaTextCompletion(ai_model_id=model_id, host=host)
|
||||
async for _ in ollama.get_streaming_text_contents(
|
||||
chat_history,
|
||||
OllamaTextPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
):
|
||||
pass
|
||||
|
||||
mock_client.assert_called_once_with(host=host)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.generate")
|
||||
async def test_completion(mock_completion_client, model_id, service_id, prompt, default_options):
|
||||
"""Test that the service generates content correctly."""
|
||||
mock_completion_client.return_value = {"response": "test_response"}
|
||||
|
||||
ollama = OllamaTextCompletion(ai_model_id=model_id)
|
||||
response = await ollama.get_text_contents(
|
||||
prompt=prompt,
|
||||
settings=OllamaTextPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
)
|
||||
|
||||
assert response[0].text == "test_response"
|
||||
mock_completion_client.assert_called_once_with(
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
options=default_options,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.generate")
|
||||
async def test_completion_wrong_return_type(
|
||||
mock_completion_client,
|
||||
mock_streaming_text_response,
|
||||
model_id,
|
||||
service_id,
|
||||
chat_history,
|
||||
default_options,
|
||||
):
|
||||
"""Test that the completion service fails when the return type is incorrect."""
|
||||
mock_completion_client.return_value = mock_streaming_text_response # should not be a streaming response
|
||||
|
||||
ollama = OllamaTextCompletion(ai_model_id=model_id)
|
||||
with pytest.raises(ServiceInvalidResponseError):
|
||||
await ollama.get_text_contents(
|
||||
chat_history,
|
||||
OllamaTextPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.generate")
|
||||
async def test_streaming_completion(
|
||||
mock_completion_client,
|
||||
mock_streaming_text_response,
|
||||
model_id,
|
||||
service_id,
|
||||
prompt,
|
||||
default_options,
|
||||
):
|
||||
"""Test that the service generates streaming content correctly."""
|
||||
mock_completion_client.return_value = mock_streaming_text_response
|
||||
|
||||
ollama = OllamaTextCompletion(ai_model_id=model_id)
|
||||
response = ollama.get_streaming_text_contents(
|
||||
prompt,
|
||||
OllamaTextPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
)
|
||||
|
||||
responses = []
|
||||
async for line in response:
|
||||
assert line[0].text == "test_response"
|
||||
responses.append(line)
|
||||
assert len(responses) == 1
|
||||
|
||||
mock_completion_client.assert_called_once_with(
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
options=default_options,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.generate")
|
||||
async def test_streaming_completion_wrong_return_type(
|
||||
mock_completion_client,
|
||||
model_id,
|
||||
service_id,
|
||||
chat_history,
|
||||
default_options,
|
||||
):
|
||||
"""Test that the streaming completion service fails when the return type is incorrect."""
|
||||
mock_completion_client.return_value = {"response": "test_response"} # should not be a non-streaming response
|
||||
|
||||
ollama = OllamaTextCompletion(ai_model_id=model_id)
|
||||
with pytest.raises(ServiceInvalidResponseError):
|
||||
async for _ in ollama.get_streaming_text_contents(
|
||||
chat_history,
|
||||
OllamaTextPromptExecutionSettings(service_id=service_id, options=default_options),
|
||||
):
|
||||
pass
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy
|
||||
import pytest
|
||||
from numpy import array
|
||||
|
||||
from semantic_kernel.connectors.ai.ollama.ollama_prompt_execution_settings import OllamaEmbeddingPromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.ollama.services.ollama_text_embedding import OllamaTextEmbedding
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
def test_init_empty_service_id(model_id):
|
||||
"""Test that the service initializes correctly with an empty service id."""
|
||||
ollama = OllamaTextEmbedding(ai_model_id=model_id)
|
||||
assert ollama.service_id == model_id
|
||||
|
||||
|
||||
def test_custom_client(model_id, custom_client):
|
||||
"""Test that the service initializes correctly with a custom client."""
|
||||
ollama = OllamaTextEmbedding(ai_model_id=model_id, client=custom_client)
|
||||
assert ollama.client == custom_client
|
||||
|
||||
|
||||
def test_invalid_ollama_settings():
|
||||
"""Test that the service initializes incorrectly with invalid settings."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
_ = OllamaTextEmbedding(ai_model_id=123)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OLLAMA_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_init_empty_model_id(ollama_unit_test_env):
|
||||
"""Test that the service initializes incorrectly with an empty model id."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
_ = OllamaTextEmbedding(env_file_path="fake_env_file_path.env")
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.__init__", return_value=None) # mock_client
|
||||
@patch("ollama.AsyncClient.embeddings") # mock_embedding_client
|
||||
async def test_custom_host(mock_embedding_client, mock_client, model_id, host, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly with a custom host."""
|
||||
mock_embedding_client.return_value = {"embedding": [0.1, 0.2, 0.3]}
|
||||
|
||||
ollama = OllamaTextEmbedding(ai_model_id=model_id, host=host)
|
||||
_ = await ollama.generate_embeddings(
|
||||
[prompt],
|
||||
)
|
||||
|
||||
mock_client.assert_called_once_with(host=host)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.embeddings")
|
||||
async def test_embedding(mock_embedding_client, model_id, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly."""
|
||||
mock_embedding_client.return_value = {"embedding": [0.1, 0.2, 0.3]}
|
||||
settings = OllamaEmbeddingPromptExecutionSettings()
|
||||
settings.options = {"test_key": "test_value"}
|
||||
|
||||
ollama = OllamaTextEmbedding(ai_model_id=model_id)
|
||||
response = await ollama.generate_embeddings(
|
||||
[prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert response.all() == array([0.1, 0.2, 0.3]).all()
|
||||
mock_embedding_client.assert_called_once_with(model=model_id, prompt=prompt, options=settings.options)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.embeddings")
|
||||
async def test_embedding_list_input(mock_embedding_client, model_id, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly with a list of prompts."""
|
||||
mock_embedding_client.return_value = {"embedding": [0.1, 0.2, 0.3]}
|
||||
settings = OllamaEmbeddingPromptExecutionSettings()
|
||||
settings.options = {"test_key": "test_value"}
|
||||
|
||||
ollama = OllamaTextEmbedding(ai_model_id=model_id)
|
||||
responses = await ollama.generate_embeddings(
|
||||
[prompt, prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert type(responses) is numpy.ndarray
|
||||
assert all(type(response) is numpy.ndarray for response in responses)
|
||||
assert mock_embedding_client.call_count == 2
|
||||
mock_embedding_client.assert_called_with(model=model_id, prompt=prompt, options=settings.options)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.embeddings")
|
||||
async def test_raw_embedding(mock_embedding_client, model_id, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly."""
|
||||
mock_embedding_client.return_value = {"embedding": [0.1, 0.2, 0.3]}
|
||||
settings = OllamaEmbeddingPromptExecutionSettings()
|
||||
settings.options = {"test_key": "test_value"}
|
||||
|
||||
ollama = OllamaTextEmbedding(ai_model_id=model_id)
|
||||
response = await ollama.generate_raw_embeddings(
|
||||
[prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert response == [[0.1, 0.2, 0.3]]
|
||||
mock_embedding_client.assert_called_once_with(model=model_id, prompt=prompt, options=settings.options)
|
||||
|
||||
|
||||
@patch("ollama.AsyncClient.embeddings")
|
||||
async def test_raw_embedding_list_input(mock_embedding_client, model_id, prompt):
|
||||
"""Test that the service initializes and generates embeddings correctly with a list of prompts."""
|
||||
mock_embedding_client.return_value = {"embedding": [0.1, 0.2, 0.3]}
|
||||
settings = OllamaEmbeddingPromptExecutionSettings()
|
||||
settings.options = {"test_key": "test_value"}
|
||||
|
||||
ollama = OllamaTextEmbedding(ai_model_id=model_id)
|
||||
responses = await ollama.generate_raw_embeddings(
|
||||
[prompt, prompt],
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert responses == [[0.1, 0.2, 0.3], [0.1, 0.2, 0.3]]
|
||||
assert mock_embedding_client.call_count == 2
|
||||
mock_embedding_client.assert_called_with(model=model_id, prompt=prompt, options=settings.options)
|
||||
@@ -0,0 +1,240 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.function_call_choice_configuration import FunctionCallChoiceConfiguration
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceType
|
||||
|
||||
# The code under test
|
||||
from semantic_kernel.connectors.ai.ollama.services.utils import (
|
||||
MESSAGE_CONVERTERS,
|
||||
update_settings_from_function_choice_configuration,
|
||||
)
|
||||
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.image_content import ImageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_message_content() -> ChatMessageContent:
|
||||
"""Fixture to create a basic ChatMessageContent object with role=USER and simple text content."""
|
||||
return ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
content="Hello, I am a user message.", # The text content
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_system_message_content() -> ChatMessageContent:
|
||||
"""Fixture to create a ChatMessageContent object with role=SYSTEM."""
|
||||
return ChatMessageContent(role=AuthorRole.SYSTEM, content="This is a system message.")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_assistant_message_content() -> ChatMessageContent:
|
||||
"""Fixture to create a ChatMessageContent object with role=ASSISTANT."""
|
||||
return ChatMessageContent(role=AuthorRole.ASSISTANT, content="This is an assistant message.")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_message_content() -> ChatMessageContent:
|
||||
"""Fixture to create a ChatMessageContent object with role=TOOL."""
|
||||
return ChatMessageContent(role=AuthorRole.TOOL, content="This is a tool message.")
|
||||
|
||||
|
||||
def test_message_converters_system(mock_system_message_content: ChatMessageContent) -> None:
|
||||
"""Test that passing a system message returns the correct dictionary structure for 'system' role."""
|
||||
# Act
|
||||
converter = MESSAGE_CONVERTERS[AuthorRole.SYSTEM]
|
||||
result = converter(mock_system_message_content)
|
||||
|
||||
# Assert
|
||||
assert result["role"] == "system", "Expected role to be 'system' on the returned message."
|
||||
assert result["content"] == mock_system_message_content.content, (
|
||||
"Expected content to match the system message content."
|
||||
)
|
||||
|
||||
|
||||
def test_message_converters_user_no_images(mock_chat_message_content: ChatMessageContent) -> None:
|
||||
"""Test that passing a user message without images returns correct dictionary structure for 'user' role."""
|
||||
# Act
|
||||
converter = MESSAGE_CONVERTERS[AuthorRole.USER]
|
||||
result = converter(mock_chat_message_content)
|
||||
|
||||
# Assert
|
||||
assert result["role"] == "user", "Expected role to be 'user' on the returned message."
|
||||
assert result["content"] == mock_chat_message_content.content, "Expected content to match the user message content."
|
||||
# Ensure that no 'images' field is added
|
||||
assert "images" not in result, "No images should be present if no ImageContent is added."
|
||||
|
||||
|
||||
def test_message_converters_user_with_images() -> None:
|
||||
"""Test user message with multiple images, verifying the 'images' field is populated."""
|
||||
# Arrange
|
||||
img1 = ImageContent(data="some_base64_data")
|
||||
img2 = ImageContent(data="other_base64_data")
|
||||
content = ChatMessageContent(role=AuthorRole.USER, items=[img1, img2], content="User with images")
|
||||
|
||||
# Act
|
||||
converter = MESSAGE_CONVERTERS[AuthorRole.USER]
|
||||
result = converter(content)
|
||||
|
||||
# Assert
|
||||
assert result["role"] == "user"
|
||||
assert result["content"] == content.content
|
||||
assert "images" in result, "Images field expected when ImageContent is present."
|
||||
assert len(result["images"]) == 2, "Two images should be in the 'images' field."
|
||||
assert result["images"] == [b"some_base64_data", b"other_base64_data"], (
|
||||
"Image data should match the content from ImageContent."
|
||||
)
|
||||
|
||||
|
||||
def test_message_converters_user_with_image_missing_data() -> None:
|
||||
"""Test user message with image content that has missing data, expecting ValueError."""
|
||||
# Arrange
|
||||
bad_image = ImageContent(data="") # empty data for image
|
||||
content = ChatMessageContent(role=AuthorRole.USER, items=[bad_image])
|
||||
|
||||
# Act & Assert
|
||||
converter = MESSAGE_CONVERTERS[AuthorRole.USER]
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
converter(content)
|
||||
|
||||
assert "Image item must contain data encoded as base64." in str(exc_info.value), (
|
||||
"Should raise ValueError for missing base64 data in image."
|
||||
)
|
||||
|
||||
|
||||
def test_message_converters_assistant_basic(mock_assistant_message_content: ChatMessageContent) -> None:
|
||||
"""Test assistant message without images or tool calls."""
|
||||
# Act
|
||||
converter = MESSAGE_CONVERTERS[AuthorRole.ASSISTANT]
|
||||
result = converter(mock_assistant_message_content)
|
||||
|
||||
# Assert
|
||||
assert result["role"] == "assistant", "Assistant role expected."
|
||||
assert result["content"] == mock_assistant_message_content.content
|
||||
assert "images" not in result, "No images included, so should not have an 'images' field."
|
||||
assert "tool_calls" not in result, "No FunctionCallContent, so 'tool_calls' field shouldn't be present."
|
||||
|
||||
|
||||
def test_message_converters_assistant_with_image() -> None:
|
||||
"""Test assistant message containing images. Verify 'images' field is added."""
|
||||
# Arrange
|
||||
img = ImageContent(data="assistant_base64_data")
|
||||
content = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[img], content="Assistant image message")
|
||||
|
||||
# Act
|
||||
converter = MESSAGE_CONVERTERS[AuthorRole.ASSISTANT]
|
||||
result = converter(content)
|
||||
|
||||
# Assert
|
||||
assert result["role"] == "assistant"
|
||||
assert result["content"] == content.content
|
||||
assert "images" in result, "Images should be included for assistant messages with ImageContent."
|
||||
assert result["images"] == [b"assistant_base64_data"], "Expected matching base64 data in images."
|
||||
|
||||
|
||||
def test_message_converters_assistant_with_tool_calls() -> None:
|
||||
"""Test assistant message with FunctionCallContent should populate 'tool_calls'."""
|
||||
# Arrange
|
||||
tool_call_1 = FunctionCallContent(function_name="foo", arguments='{"key": "value"}')
|
||||
tool_call_2 = FunctionCallContent(function_name="bar", arguments='{"another": "123"}')
|
||||
|
||||
content = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, items=[tool_call_1, tool_call_2], content="Assistant with tools"
|
||||
)
|
||||
|
||||
# Act
|
||||
converter = MESSAGE_CONVERTERS[AuthorRole.ASSISTANT]
|
||||
result = converter(content)
|
||||
|
||||
# Assert
|
||||
assert result["role"] == "assistant"
|
||||
assert result["content"] == content.content
|
||||
assert "tool_calls" in result, "tool_calls field should be present for assistant messages with FunctionCallContent."
|
||||
assert len(result["tool_calls"]) == 2, "Expected two tool calls in the result."
|
||||
assert result["tool_calls"][0]["function"]["name"] == "foo", "First tool call function name mismatched."
|
||||
assert result["tool_calls"][0]["function"]["arguments"] == {"key": "value"}, "Expected arguments to be JSON loaded."
|
||||
assert result["tool_calls"][1]["function"]["name"] == "bar", "Second tool call function name mismatched."
|
||||
assert result["tool_calls"][1]["function"]["arguments"] == {"another": "123"}, (
|
||||
"Expected arguments to be JSON loaded."
|
||||
)
|
||||
|
||||
|
||||
def test_message_converters_tool_with_result() -> None:
|
||||
"""Test tool message with a FunctionResultContent, verifying the message content is set."""
|
||||
# Arrange
|
||||
fr_content = FunctionResultContent(id="some_id", result="some result", function_name="test_func")
|
||||
tool_message = ChatMessageContent(role=AuthorRole.TOOL, items=[fr_content])
|
||||
|
||||
# Act
|
||||
converter = MESSAGE_CONVERTERS[AuthorRole.TOOL]
|
||||
result = converter(tool_message)
|
||||
|
||||
# Assert
|
||||
assert result["role"] == "tool", "Expected role to be 'tool' for a tool message."
|
||||
# The code takes the first FunctionResultContent's result as the content
|
||||
assert result["content"] == fr_content.result, "Expected content to match the function result."
|
||||
|
||||
|
||||
def test_message_converters_tool_missing_function_result_content(mock_tool_message_content: ChatMessageContent) -> None:
|
||||
"""Test that if no FunctionResultContent is present, ValueError is raised."""
|
||||
# Arrange
|
||||
mock_tool_message_content.items = [] # no FunctionResultContent in items
|
||||
converter = MESSAGE_CONVERTERS[AuthorRole.TOOL]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
converter(mock_tool_message_content)
|
||||
assert "Tool message must have a function result content item." in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("choice_type", [FunctionChoiceType.AUTO, FunctionChoiceType.NONE, FunctionChoiceType.REQUIRED])
|
||||
def test_update_settings_from_function_choice_configuration(choice_type: FunctionChoiceType) -> None:
|
||||
"""Test that update_settings_from_function_choice_configuration updates the settings with the correct tools."""
|
||||
# Arrange
|
||||
# We'll create a mock configuration with some available functions.
|
||||
mock_config = FunctionCallChoiceConfiguration()
|
||||
mock_config.available_functions = [MagicMock() for _ in range(2)]
|
||||
|
||||
# We also patch the kernel_function_metadata_to_function_call_format function.
|
||||
# The function returns a dict object describing each function.
|
||||
mock_tool_description = {"type": "function", "function": {"name": "mocked_function"}}
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.connectors.ai.ollama.services.utils.kernel_function_metadata_to_function_call_format",
|
||||
return_value=mock_tool_description,
|
||||
):
|
||||
settings = PromptExecutionSettings()
|
||||
|
||||
# Act
|
||||
update_settings_from_function_choice_configuration(
|
||||
function_choice_configuration=mock_config,
|
||||
settings=settings,
|
||||
type=choice_type,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# After the call, either settings.tools or settings.extension_data["tools"] should be set.
|
||||
# The code tries settings.tools first and if it fails, it sets extension_data["tools"].
|
||||
# We'll check both possibilities.
|
||||
possible_tools = getattr(settings, "tools", None)
|
||||
|
||||
if possible_tools is not None:
|
||||
# If settings.tools exists, ensure it got updated
|
||||
assert len(possible_tools) == 2, "Should have exactly two tools set in the settings.tools attribute."
|
||||
assert possible_tools[0]["function"]["name"] == "mocked_function", (
|
||||
"Expected mocked function name in settings.tools."
|
||||
)
|
||||
else:
|
||||
# Otherwise check for extension_data
|
||||
assert "tools" in settings.extension_data, "Expected 'tools' in extension_data if settings.tools not present."
|
||||
assert len(settings.extension_data["tools"]) == 2, "Should have exactly two tools in extension_data."
|
||||
assert settings.extension_data["tools"][0]["function"]["name"] == "mocked_function", (
|
||||
"Expected mocked function name in extension_data."
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.ai.ollama import OllamaPromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.ollama.ollama_prompt_execution_settings import (
|
||||
OllamaChatPromptExecutionSettings,
|
||||
OllamaTextPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
|
||||
|
||||
def test_default_ollama_prompt_execution_settings():
|
||||
settings = OllamaPromptExecutionSettings()
|
||||
|
||||
assert settings.format is None
|
||||
assert settings.options is None
|
||||
|
||||
|
||||
def test_custom_ollama_prompt_execution_settings():
|
||||
settings = OllamaPromptExecutionSettings(
|
||||
format="json",
|
||||
options={
|
||||
"key": "value",
|
||||
},
|
||||
)
|
||||
|
||||
assert settings.format == "json"
|
||||
assert settings.options == {"key": "value"}
|
||||
|
||||
|
||||
def test_ollama_prompt_execution_settings_from_default_completion_config():
|
||||
settings = PromptExecutionSettings(service_id="test_service")
|
||||
chat_settings = OllamaChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.format is None
|
||||
assert chat_settings.options is None
|
||||
|
||||
|
||||
def test_ollama_prompt_execution_settings_from_openai_prompt_execution_settings():
|
||||
chat_settings = OllamaChatPromptExecutionSettings(service_id="test_service", options={"temperature": 0.5})
|
||||
new_settings = OllamaPromptExecutionSettings(service_id="test_2", options={"temperature": 0.0})
|
||||
chat_settings.update_from_prompt_execution_settings(new_settings)
|
||||
|
||||
assert chat_settings.service_id == "test_2"
|
||||
assert chat_settings.options["temperature"] == 0.0
|
||||
|
||||
|
||||
def test_ollama_prompt_execution_settings_from_custom_completion_config():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"format": "json",
|
||||
"options": {
|
||||
"key": "value",
|
||||
},
|
||||
},
|
||||
)
|
||||
chat_settings = OllamaChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.format == "json"
|
||||
assert chat_settings.options == {"key": "value"}
|
||||
|
||||
|
||||
def test_ollama_chat_prompt_execution_settings_from_custom_completion_config_with_functions():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"tools": [{"function": {}}],
|
||||
},
|
||||
)
|
||||
chat_settings = OllamaChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
|
||||
assert chat_settings.tools == [{"function": {}}]
|
||||
|
||||
|
||||
def test_create_options():
|
||||
settings = OllamaChatPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"format": "json",
|
||||
},
|
||||
)
|
||||
options = settings.prepare_settings_dict()
|
||||
|
||||
assert options["format"] == "json"
|
||||
|
||||
|
||||
def test_default_ollama_text_prompt_execution_settings():
|
||||
settings = OllamaTextPromptExecutionSettings()
|
||||
|
||||
assert settings.system is None
|
||||
assert settings.template is None
|
||||
assert settings.context is None
|
||||
assert settings.raw is None
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class MockResponse:
|
||||
def __init__(self, response, status=200):
|
||||
self._response = response
|
||||
self.status = status
|
||||
|
||||
async def text(self):
|
||||
return self._response
|
||||
|
||||
async def json(self):
|
||||
return self._response
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
async def content(self):
|
||||
yield json.dumps(self._response).encode("utf-8")
|
||||
yield json.dumps({"done": True}).encode("utf-8")
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -0,0 +1,32 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
from pytest import fixture
|
||||
|
||||
|
||||
@fixture()
|
||||
def onnx_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for OnnxGenAISettings."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"ONNX_GEN_AI_CHAT_MODEL_FOLDER": "test",
|
||||
"ONNX_GEN_AI_TEXT_MODEL_FOLDER": "test",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
gen_ai_config = {"model": {"test": "test"}}
|
||||
|
||||
gen_ai_config_vision = {"model": {"vision": "test"}}
|
||||
@@ -0,0 +1,187 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.onnx import OnnxGenAIChatCompletion, OnnxGenAIPromptExecutionSettings, ONNXTemplate
|
||||
from semantic_kernel.contents import AuthorRole, ChatHistory, ChatMessageContent, ImageContent
|
||||
from semantic_kernel.exceptions import ServiceInitializationError, ServiceInvalidExecutionSettingsError
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from tests.unit.connectors.ai.onnx.conftest import gen_ai_config, gen_ai_config_vision
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
def test_onnx_chat_completion_with_valid_env_variable(gen_ai_config, model, tokenizer, onnx_unit_test_env):
|
||||
service = OnnxGenAIChatCompletion(template=ONNXTemplate.PHI3, env_file_path="test.env")
|
||||
assert not service.enable_multi_modality
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config_vision))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
def test_onnx_chat_completion_with_vision_valid_env_variable(
|
||||
gen_ai_vision_config, model, tokenizer, onnx_unit_test_env
|
||||
):
|
||||
service = OnnxGenAIChatCompletion(template=ONNXTemplate.PHI3, env_file_path="test.env")
|
||||
assert service.enable_multi_modality
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
def test_onnx_chat_completion_with_valid_parameter(gen_ai_config, model, tokenizer):
|
||||
assert OnnxGenAIChatCompletion(ai_model_path="/valid_path", template=ONNXTemplate.PHI3)
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
def test_onnx_chat_completion_with_str_template(gen_ai_config, model, tokenizer):
|
||||
assert OnnxGenAIChatCompletion(ai_model_path="/valid_path", template="phi3")
|
||||
|
||||
|
||||
def test_onnx_chat_completion_with_invalid_model():
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OnnxGenAIChatCompletion(
|
||||
ai_model_path="/invalid_path",
|
||||
template=ONNXTemplate.PHI3,
|
||||
)
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config_vision))
|
||||
def test_onnx_chat_completion_with_multimodality_without_prompt_template(gen_ai_config_vision):
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OnnxGenAIChatCompletion()
|
||||
|
||||
|
||||
def test_onnx_chat_completion_with_invalid_env_variable(onnx_unit_test_env):
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OnnxGenAIChatCompletion(
|
||||
template=ONNXTemplate.PHI3,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["ONNX_GEN_AI_CHAT_MODEL_FOLDER"]], indirect=True)
|
||||
def test_onnx_chat_completion_with_missing_ai_path(onnx_unit_test_env):
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OnnxGenAIChatCompletion(template=ONNXTemplate.PHI3, env_file_path="test.env")
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
async def test_onnx_chat_completion(gen_ai_config, model, tokenizer):
|
||||
generator_mock = MagicMock()
|
||||
generator_mock.__aiter__.return_value = [["H"], ["e"], ["l"], ["l"], ["o"]]
|
||||
|
||||
chat_completion = OnnxGenAIChatCompletion(template=ONNXTemplate.PHI3, ai_model_path="test")
|
||||
|
||||
history = ChatHistory()
|
||||
history.add_system_message("test")
|
||||
history.add_user_message("test")
|
||||
|
||||
with patch.object(chat_completion, "_generate_next_token_async", return_value=generator_mock):
|
||||
completed_text: ChatMessageContent = await chat_completion.get_chat_message_content(
|
||||
prompt="test", chat_history=history, settings=OnnxGenAIPromptExecutionSettings(), kernel=Kernel()
|
||||
)
|
||||
|
||||
assert str(completed_text) == "Hello"
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
async def test_onnx_chat_completion_streaming(gen_ai_config, model, tokenizer):
|
||||
generator_mock = MagicMock()
|
||||
generator_mock.__aiter__.return_value = [["H"], ["e"], ["l"], ["l"], ["o"]]
|
||||
|
||||
chat_completion = OnnxGenAIChatCompletion(template=ONNXTemplate.PHI3, ai_model_path="test")
|
||||
|
||||
history = ChatHistory()
|
||||
history.add_system_message("test")
|
||||
history.add_user_message("test")
|
||||
|
||||
completed_text: str = ""
|
||||
|
||||
with patch.object(chat_completion, "_generate_next_token_async", return_value=generator_mock):
|
||||
async for chunk in chat_completion.get_streaming_chat_message_content(
|
||||
prompt="test", chat_history=history, settings=OnnxGenAIPromptExecutionSettings(), kernel=Kernel()
|
||||
):
|
||||
completed_text += str(chunk)
|
||||
|
||||
assert completed_text == "Hello"
|
||||
|
||||
|
||||
@patch("onnxruntime_genai.Model")
|
||||
def test_onnx_chat_get_image_history(model):
|
||||
builtin_open = open # save the unpatched version
|
||||
|
||||
def patch_open(*args, **kwargs):
|
||||
if "genai_config.json" in str(args[0]):
|
||||
# mocked open for path "genai_config.json"
|
||||
return mock_open(read_data=json.dumps(gen_ai_config_vision))(*args, **kwargs)
|
||||
# unpatched version for every other path
|
||||
return builtin_open(*args, **kwargs)
|
||||
|
||||
with patch("builtins.open", patch_open):
|
||||
chat_completion = OnnxGenAIChatCompletion(
|
||||
template=ONNXTemplate.PHI3,
|
||||
ai_model_path="test",
|
||||
)
|
||||
|
||||
image_content = ImageContent.from_image_path(
|
||||
image_path=os.path.join(os.path.dirname(__file__), "../../../../../", "assets/sample_image.jpg")
|
||||
)
|
||||
|
||||
history = ChatHistory()
|
||||
history.add_system_message("test")
|
||||
history.add_user_message("test")
|
||||
history.add_message(
|
||||
ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[image_content],
|
||||
),
|
||||
)
|
||||
|
||||
last_image = chat_completion._get_images_from_history(history)
|
||||
assert last_image == [image_content]
|
||||
|
||||
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
async def test_onnx_chat_get_image_history_with_not_multimodal(model, tokenizer):
|
||||
builtin_open = open # save the unpatched version
|
||||
|
||||
def patch_open(*args, **kwargs):
|
||||
if "genai_config.json" in str(args[0]):
|
||||
# mocked open for path "genai_config.json"
|
||||
return mock_open(read_data=json.dumps(gen_ai_config))(*args, **kwargs)
|
||||
# unpatched version for every other path
|
||||
return builtin_open(*args, **kwargs)
|
||||
|
||||
with patch("builtins.open", patch_open):
|
||||
chat_completion = OnnxGenAIChatCompletion(
|
||||
template=ONNXTemplate.PHI3,
|
||||
ai_model_path="test",
|
||||
)
|
||||
|
||||
image_content = ImageContent.from_image_path(
|
||||
image_path=os.path.join(os.path.dirname(__file__), "../../../../../", "assets/sample_image.jpg")
|
||||
)
|
||||
|
||||
history = ChatHistory()
|
||||
history.add_system_message("test")
|
||||
history.add_user_message("test")
|
||||
history.add_message(
|
||||
ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
items=[image_content],
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
_ = await chat_completion._get_images_from_history(history)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import json
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.onnx import OnnxGenAIPromptExecutionSettings, OnnxGenAITextCompletion # noqa: E402
|
||||
from semantic_kernel.contents import TextContent
|
||||
from semantic_kernel.exceptions import ServiceInitializationError
|
||||
from tests.unit.connectors.ai.onnx.conftest import gen_ai_config
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
def test_onnx_chat_completion_with_valid_env_variable(gen_ai_config, model, tokenizer, onnx_unit_test_env):
|
||||
assert OnnxGenAITextCompletion(env_file_path="test.env")
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
def test_onnx_chat_completion_with_valid_parameter(gen_ai_config, model, tokenizer):
|
||||
assert OnnxGenAITextCompletion(ai_model_path="/valid_path")
|
||||
|
||||
|
||||
def test_onnx_chat_completion_with_invalid_model():
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OnnxGenAITextCompletion(ai_model_path="/invalid_path")
|
||||
|
||||
|
||||
def test_onnx_chat_completion_with_invalid_env_variable(onnx_unit_test_env):
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OnnxGenAITextCompletion()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["ONNX_GEN_AI_TEXT_MODEL_FOLDER"]], indirect=True)
|
||||
def test_onnx_chat_completion_with_missing_ai_path(onnx_unit_test_env):
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OnnxGenAITextCompletion(env_file_path="test.env")
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
async def test_onnx_text_completion(gen_ai_config, model, tokenizer):
|
||||
generator_mock = MagicMock()
|
||||
generator_mock.__aiter__.return_value = [["H"], ["e"], ["l"], ["l"], ["o"]]
|
||||
|
||||
text_completion = OnnxGenAITextCompletion(ai_model_path="test")
|
||||
with patch.object(text_completion, "_generate_next_token_async", return_value=generator_mock):
|
||||
completed_text: TextContent = await text_completion.get_text_content(
|
||||
prompt="test", settings=OnnxGenAIPromptExecutionSettings()
|
||||
)
|
||||
|
||||
assert completed_text.text == "Hello"
|
||||
|
||||
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=json.dumps(gen_ai_config))
|
||||
@patch("onnxruntime_genai.Model")
|
||||
@patch("onnxruntime_genai.Tokenizer")
|
||||
async def test_onnx_text_completion_streaming(gen_ai_config, model, tokenizer):
|
||||
generator_mock = MagicMock()
|
||||
generator_mock.__aiter__.return_value = [["H"], ["e"], ["l"], ["l"], ["o"]]
|
||||
|
||||
text_completion = OnnxGenAITextCompletion(ai_model_path="test")
|
||||
completed_text: str = ""
|
||||
with patch.object(text_completion, "_generate_next_token_async", return_value=generator_mock):
|
||||
async for chunk in text_completion.get_streaming_text_content(
|
||||
prompt="test", settings=OnnxGenAIPromptExecutionSettings()
|
||||
):
|
||||
completed_text += chunk.text
|
||||
|
||||
assert completed_text == "Hello"
|
||||
@@ -0,0 +1,132 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.ai.onnx.utils import (
|
||||
gemma_template,
|
||||
llama_template,
|
||||
phi3_template,
|
||||
phi3v_template,
|
||||
phi4_template,
|
||||
phi4mm_template,
|
||||
)
|
||||
from semantic_kernel.contents import AudioContent, AuthorRole, ChatHistory, ImageContent, TextContent
|
||||
|
||||
|
||||
def test_phi3v_template_with_text_and_image():
|
||||
history = ChatHistory(
|
||||
messages=[
|
||||
{"role": AuthorRole.SYSTEM, "content": "System message"},
|
||||
{
|
||||
"role": AuthorRole.USER,
|
||||
"items": [TextContent(text="User text message"), ImageContent(url="http://example.com/image.png")],
|
||||
},
|
||||
{"role": AuthorRole.ASSISTANT, "content": "Assistant message"},
|
||||
]
|
||||
)
|
||||
|
||||
expected_output = (
|
||||
"<|system|>\nSystem message<|end|>\n"
|
||||
"<|user|>\nUser text message<|end|>\n"
|
||||
"<|image_1|>\n"
|
||||
"<|assistant|>\nAssistant message<|end|>\n"
|
||||
"<|assistant|>\n"
|
||||
)
|
||||
|
||||
assert phi3v_template(history) == expected_output
|
||||
|
||||
|
||||
def test_phi4mm_template_with_text_and_image():
|
||||
history = ChatHistory(
|
||||
messages=[
|
||||
{"role": AuthorRole.SYSTEM, "content": "System message"},
|
||||
{
|
||||
"role": AuthorRole.USER,
|
||||
"items": [
|
||||
TextContent(text="User text message"),
|
||||
ImageContent(url="http://example.com/image.png"),
|
||||
AudioContent(url="http://example.com/audio.mp3"),
|
||||
],
|
||||
},
|
||||
{"role": AuthorRole.ASSISTANT, "content": "Assistant message"},
|
||||
]
|
||||
)
|
||||
|
||||
expected_output = (
|
||||
"<|system|>\nSystem message<|end|>\n"
|
||||
"<|user|>\nUser text message<|end|>\n"
|
||||
"<|image_1|>\n"
|
||||
"<|audio_1|>\n"
|
||||
"<|assistant|>\nAssistant message<|end|>\n"
|
||||
"<|assistant|>\n"
|
||||
)
|
||||
|
||||
assert phi4mm_template(history) == expected_output
|
||||
|
||||
|
||||
def test_phi3_template_with_only_text():
|
||||
history = ChatHistory(messages=[{"role": AuthorRole.USER, "items": [TextContent(text="User text message")]}])
|
||||
|
||||
expected_output = "<|user|>\nUser text message<|end|>\n<|assistant|>\n"
|
||||
|
||||
assert phi3_template(history) == expected_output
|
||||
|
||||
|
||||
def test_phi4_template_with_only_text():
|
||||
history = ChatHistory(messages=[{"role": AuthorRole.USER, "items": [TextContent(text="User text message")]}])
|
||||
|
||||
expected_output = "<|user|>\nUser text message<|end|>\n<|assistant|>\n"
|
||||
|
||||
assert phi4_template(history) == expected_output
|
||||
|
||||
|
||||
def test_gemma_template_with_user_and_assistant_messages():
|
||||
history = ChatHistory(
|
||||
messages=[
|
||||
{"role": AuthorRole.USER, "content": "User text message"},
|
||||
{"role": AuthorRole.ASSISTANT, "content": "Assistant message"},
|
||||
]
|
||||
)
|
||||
|
||||
expected_output = (
|
||||
"<bos>"
|
||||
"<start_of_turn>user\nUser text message<end_of_turn>\n"
|
||||
"<start_of_turn>model\nAssistant message<end_of_turn>\n"
|
||||
"<start_of_turn>model\n"
|
||||
)
|
||||
|
||||
assert gemma_template(history) == expected_output
|
||||
|
||||
|
||||
def test_gemma_template_with_only_user_message():
|
||||
history = ChatHistory(messages=[{"role": AuthorRole.USER, "content": "User text message"}])
|
||||
|
||||
expected_output = "<bos><start_of_turn>user\nUser text message<end_of_turn>\n<start_of_turn>model\n"
|
||||
|
||||
assert gemma_template(history) == expected_output
|
||||
|
||||
|
||||
def test_llama_template_with_user_and_assistant_messages():
|
||||
history = ChatHistory(
|
||||
messages=[
|
||||
{"role": AuthorRole.USER, "content": "User text message"},
|
||||
{"role": AuthorRole.ASSISTANT, "content": "Assistant message"},
|
||||
]
|
||||
)
|
||||
|
||||
expected_output = (
|
||||
"<|start_header_id|>user<|end_header_id|>\n\nUser text message<|eot_id|>"
|
||||
"<|start_header_id|>assistant<|end_header_id|>\n\nAssistant message<|eot_id|>"
|
||||
"<|start_header_id|>assistant<|end_header_id|>"
|
||||
)
|
||||
|
||||
assert llama_template(history) == expected_output
|
||||
|
||||
|
||||
def test_llama_template_with_only_user_message():
|
||||
history = ChatHistory(messages=[{"role": AuthorRole.USER, "content": "User text message"}])
|
||||
|
||||
expected_output = (
|
||||
"<|start_header_id|>user<|end_header_id|>\n\nUser text message<|eot_id|>"
|
||||
"<|start_header_id|>assistant<|end_header_id|>"
|
||||
)
|
||||
|
||||
assert llama_template(history) == expected_output
|
||||
@@ -0,0 +1,84 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from semantic_kernel.connectors.ai.onnx.onnx_gen_ai_prompt_execution_settings import (
|
||||
OnnxGenAIPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
|
||||
|
||||
def test_default_onnx_chat_prompt_execution_settings():
|
||||
settings = OnnxGenAIPromptExecutionSettings()
|
||||
assert settings.temperature is None
|
||||
assert settings.top_p is None
|
||||
|
||||
|
||||
def test_custom_onnx_chat_prompt_execution_settings():
|
||||
settings = OnnxGenAIPromptExecutionSettings(
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
max_length=128,
|
||||
)
|
||||
assert settings.temperature == 0.5
|
||||
assert settings.top_p == 0.5
|
||||
assert settings.max_length == 128
|
||||
|
||||
|
||||
def test_onnx_chat_prompt_execution_settings_from_default_completion_config():
|
||||
settings = PromptExecutionSettings(service_id="test_service")
|
||||
chat_settings = OnnxGenAIPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.temperature is None
|
||||
assert chat_settings.top_p is None
|
||||
|
||||
|
||||
def test_onnx_chat_prompt_execution_settings_from_onnx_prompt_execution_settings():
|
||||
chat_settings = OnnxGenAIPromptExecutionSettings(service_id="test_service", temperature=1.0)
|
||||
new_settings = OnnxGenAIPromptExecutionSettings(service_id="test_2", temperature=0.0)
|
||||
chat_settings.update_from_prompt_execution_settings(new_settings)
|
||||
assert chat_settings.service_id == "test_2"
|
||||
assert chat_settings.temperature == 0.0
|
||||
|
||||
|
||||
def test_onnx_chat_prompt_execution_settings_from_custom_completion_config():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_length": 128,
|
||||
},
|
||||
)
|
||||
chat_settings = OnnxGenAIPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.max_length == 128
|
||||
|
||||
|
||||
def test_create_options():
|
||||
settings = OnnxGenAIPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"max_length": 128,
|
||||
},
|
||||
)
|
||||
options = settings.prepare_settings_dict()
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.5
|
||||
assert options["max_length"] == 128
|
||||
|
||||
|
||||
def test_create_options_with_wrong_parameter():
|
||||
with pytest.raises(ValidationError):
|
||||
OnnxGenAIPromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
function_choice_behavior="auto",
|
||||
extension_data={
|
||||
"temperature": 10.0,
|
||||
"top_p": 0.5,
|
||||
"max_length": 128,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncAzureOpenAI
|
||||
from openai.resources.audio.transcriptions import AsyncTranscriptions
|
||||
from openai.types.audio import Transcription
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai import AzureAudioToText
|
||||
from semantic_kernel.contents import AudioContent
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceInvalidRequestError
|
||||
|
||||
|
||||
def test_azure_audio_to_text_init(azure_openai_unit_test_env) -> None:
|
||||
azure_audio_to_text = AzureAudioToText()
|
||||
|
||||
assert azure_audio_to_text.client is not None
|
||||
assert isinstance(azure_audio_to_text.client, AsyncAzureOpenAI)
|
||||
assert azure_audio_to_text.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME"]], indirect=True)
|
||||
def test_azure_audio_to_text_init_with_empty_deployment_name(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="The Azure OpenAI audio to text deployment name is required."):
|
||||
AzureAudioToText(env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_KEY"]], indirect=True)
|
||||
def test_azure_audio_to_text_init_with_empty_api_key(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureAudioToText(env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True)
|
||||
def test_azure_audio_to_text_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="Please provide an endpoint or a base_url"):
|
||||
AzureAudioToText(env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True)
|
||||
def test_azure_audio_to_text_init_with_invalid_http_endpoint(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="Invalid settings: "):
|
||||
AzureAudioToText()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"AZURE_OPENAI_BASE_URL": "https://test_audio_to_text_deployment.test-base-url.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_azure_audio_to_text_init_with_from_dict(azure_openai_unit_test_env) -> None:
|
||||
default_headers = {"test_header": "test_value"}
|
||||
|
||||
settings = {
|
||||
"deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME"],
|
||||
"endpoint": azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
|
||||
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
"api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
azure_audio_to_text = AzureAudioToText.from_dict(settings=settings)
|
||||
|
||||
assert azure_audio_to_text.client is not None
|
||||
assert isinstance(azure_audio_to_text.client, AsyncAzureOpenAI)
|
||||
assert azure_audio_to_text.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME"]
|
||||
assert settings["deployment_name"] in str(azure_audio_to_text.client.base_url)
|
||||
assert azure_audio_to_text.client.api_key == azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"]
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in azure_audio_to_text.client.default_headers
|
||||
assert azure_audio_to_text.client.default_headers[key] == value
|
||||
|
||||
|
||||
async def test_azure_audio_to_text_get_text_contents(azure_openai_unit_test_env) -> None:
|
||||
audio_content = AudioContent.from_audio_file(
|
||||
os.path.join(os.path.dirname(__file__), "../../../../../", "assets/sample_audio.mp3")
|
||||
)
|
||||
|
||||
with patch.object(AsyncTranscriptions, "create", new_callable=AsyncMock) as mock_transcription_create:
|
||||
mock_transcription_create.return_value = Transcription(text="This is a test audio file.")
|
||||
|
||||
openai_audio_to_text = AzureAudioToText()
|
||||
|
||||
text_contents = await openai_audio_to_text.get_text_contents(audio_content)
|
||||
assert len(text_contents) == 1
|
||||
assert text_contents[0].text == "This is a test audio file."
|
||||
assert text_contents[0].ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME"]
|
||||
|
||||
|
||||
async def test_azure_audio_to_text_get_text_contents_invalid_audio_content(azure_openai_unit_test_env):
|
||||
audio_content = AudioContent()
|
||||
|
||||
openai_audio_to_text = AzureAudioToText()
|
||||
with pytest.raises(ServiceInvalidRequestError, match="Audio content uri must be a string to a local file."):
|
||||
await openai_audio_to_text.get_text_contents(audio_content)
|
||||
@@ -0,0 +1,960 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import openai
|
||||
import pytest
|
||||
from httpx import Request, Response
|
||||
from openai import AsyncAzureOpenAI, AsyncStream
|
||||
from openai.resources.chat.completions import AsyncCompletions as AsyncChatCompletions
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionChunk
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta
|
||||
from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
||||
|
||||
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
||||
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,
|
||||
ContentFilterResultSeverity,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.azure_chat_prompt_execution_settings import (
|
||||
AzureChatPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.const import USER_AGENT
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
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.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
|
||||
|
||||
|
||||
def test_init(azure_openai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
azure_chat_completion = AzureChatCompletion(service_id="test_service_id")
|
||||
|
||||
assert azure_chat_completion.client is not None
|
||||
assert isinstance(azure_chat_completion.client, AsyncAzureOpenAI)
|
||||
assert azure_chat_completion.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_chat_completion, ChatCompletionClientBase)
|
||||
assert azure_chat_completion.get_prompt_execution_settings_class() == AzureChatPromptExecutionSettings
|
||||
|
||||
|
||||
def test_init_client(azure_openai_unit_test_env) -> None:
|
||||
# Test successful initialization with client
|
||||
client = MagicMock(spec=AsyncAzureOpenAI)
|
||||
azure_chat_completion = AzureChatCompletion(async_client=client)
|
||||
|
||||
assert azure_chat_completion.client is not None
|
||||
assert isinstance(azure_chat_completion.client, AsyncAzureOpenAI)
|
||||
|
||||
|
||||
def test_init_base_url(azure_openai_unit_test_env) -> None:
|
||||
# Custom header for testing
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
azure_chat_completion = AzureChatCompletion(
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
assert azure_chat_completion.client is not None
|
||||
assert isinstance(azure_chat_completion.client, AsyncAzureOpenAI)
|
||||
assert azure_chat_completion.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_chat_completion, ChatCompletionClientBase)
|
||||
for key, value in default_headers.items():
|
||||
assert key in azure_chat_completion.client.default_headers
|
||||
assert azure_chat_completion.client.default_headers[key] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True)
|
||||
def test_init_endpoint(azure_openai_unit_test_env) -> None:
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
assert azure_chat_completion.client is not None
|
||||
assert isinstance(azure_chat_completion.client, AsyncAzureOpenAI)
|
||||
assert azure_chat_completion.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_chat_completion, ChatCompletionClientBase)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True)
|
||||
def test_init_with_empty_deployment_name(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureChatCompletion(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True)
|
||||
def test_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureChatCompletion(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True)
|
||||
def test_init_with_invalid_endpoint(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureChatCompletion()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True)
|
||||
def test_serialize(azure_openai_unit_test_env) -> None:
|
||||
default_headers = {"X-Test": "test"}
|
||||
|
||||
settings = {
|
||||
"deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
"endpoint": azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
|
||||
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
"api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
azure_chat_completion = AzureChatCompletion.from_dict(settings)
|
||||
dumped_settings = azure_chat_completion.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == settings["deployment_name"]
|
||||
assert settings["endpoint"] in str(dumped_settings["base_url"])
|
||||
assert settings["deployment_name"] in str(dumped_settings["base_url"])
|
||||
assert settings["api_key"] == dumped_settings["api_key"]
|
||||
assert settings["api_version"] == dumped_settings["api_version"]
|
||||
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in dumped_settings["default_headers"]
|
||||
assert dumped_settings["default_headers"][key] == value
|
||||
|
||||
# Assert that the 'User-agent' header is not present in the dumped_settings default headers
|
||||
assert USER_AGENT not in dumped_settings["default_headers"]
|
||||
|
||||
|
||||
# endregion
|
||||
# region CMC
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_completion_response() -> ChatCompletion:
|
||||
return ChatCompletion(
|
||||
id="test_id",
|
||||
choices=[
|
||||
Choice(index=0, message=ChatCompletionMessage(content="test", role="assistant"), finish_reason="stop")
|
||||
],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_streaming_chat_completion_response() -> AsyncStream[ChatCompletionChunk]:
|
||||
content = ChatCompletionChunk(
|
||||
id="test_id",
|
||||
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
stream = MagicMock(spec=AsyncStream)
|
||||
stream.__aiter__.return_value = [content]
|
||||
return stream
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc(
|
||||
mock_create,
|
||||
kernel: Kernel,
|
||||
azure_openai_unit_test_env,
|
||||
chat_history: ChatHistory,
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
) -> None:
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
chat_history.add_user_message("hello world")
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
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(chat_history),
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_with_developer_instruction_role_propagates(
|
||||
mock_create,
|
||||
kernel: Kernel,
|
||||
azure_openai_unit_test_env,
|
||||
chat_history: ChatHistory,
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
) -> None:
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
chat_history.add_user_message("hello world")
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
azure_chat_completion = AzureChatCompletion(instruction_role="developer")
|
||||
await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
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(chat_history),
|
||||
)
|
||||
assert azure_chat_completion.instruction_role == "developer"
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_with_logit_bias(
|
||||
mock_create,
|
||||
kernel: Kernel,
|
||||
azure_openai_unit_test_env,
|
||||
chat_history: ChatHistory,
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
) -> None:
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
prompt = "hello world"
|
||||
chat_history.add_user_message(prompt)
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings()
|
||||
|
||||
token_bias = {"1": -100}
|
||||
complete_prompt_execution_settings.logit_bias = token_bias
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_completion._prepare_chat_history_for_request(chat_history),
|
||||
stream=False,
|
||||
logit_bias=token_bias,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_with_stop(
|
||||
mock_create,
|
||||
azure_openai_unit_test_env,
|
||||
chat_history: ChatHistory,
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
) -> None:
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings()
|
||||
|
||||
stop = ["!"]
|
||||
complete_prompt_execution_settings.stop = stop
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings
|
||||
)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_completion._prepare_chat_history_for_request(chat_history),
|
||||
stream=False,
|
||||
stop=stop,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_azure_on_your_data(
|
||||
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="test",
|
||||
role="assistant",
|
||||
context={
|
||||
"citations": {
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "test url",
|
||||
"filepath": "test filepath",
|
||||
"chunk_id": "test chunk_id",
|
||||
},
|
||||
"intent": "query used",
|
||||
},
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
]
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
prompt = "hello world"
|
||||
messages_in = chat_history
|
||||
messages_in.add_user_message(prompt)
|
||||
messages_out = ChatHistory()
|
||||
messages_out.add_user_message(prompt)
|
||||
|
||||
expected_data_settings = {
|
||||
"data_sources": [
|
||||
{
|
||||
"type": "AzureCognitiveSearch",
|
||||
"parameters": {
|
||||
"indexName": "test_index",
|
||||
"endpoint": "https://test-endpoint-search.com",
|
||||
"key": "test_key",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings(extra_body=expected_data_settings)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
content = await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=messages_in, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
assert isinstance(content[0].items[0], FunctionCallContent)
|
||||
assert isinstance(content[0].items[1], FunctionResultContent)
|
||||
assert isinstance(content[0].items[2], TextContent)
|
||||
assert content[0].items[2].text == "test"
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_completion._prepare_chat_history_for_request(messages_out),
|
||||
stream=False,
|
||||
extra_body=expected_data_settings,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_azure_on_your_data_string(
|
||||
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="test",
|
||||
role="assistant",
|
||||
context=json.dumps({
|
||||
"citations": {
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "test url",
|
||||
"filepath": "test filepath",
|
||||
"chunk_id": "test chunk_id",
|
||||
},
|
||||
"intent": "query used",
|
||||
}),
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
]
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
prompt = "hello world"
|
||||
messages_in = chat_history
|
||||
messages_in.add_user_message(prompt)
|
||||
messages_out = ChatHistory()
|
||||
messages_out.add_user_message(prompt)
|
||||
|
||||
expected_data_settings = {
|
||||
"data_sources": [
|
||||
{
|
||||
"type": "AzureCognitiveSearch",
|
||||
"parameters": {
|
||||
"indexName": "test_index",
|
||||
"endpoint": "https://test-endpoint-search.com",
|
||||
"key": "test_key",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings(extra_body=expected_data_settings)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
content = await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=messages_in, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
assert isinstance(content[0].items[0], FunctionCallContent)
|
||||
assert isinstance(content[0].items[1], FunctionResultContent)
|
||||
assert isinstance(content[0].items[2], TextContent)
|
||||
assert content[0].items[2].text == "test"
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_completion._prepare_chat_history_for_request(messages_out),
|
||||
stream=False,
|
||||
extra_body=expected_data_settings,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_azure_on_your_data_fail(
|
||||
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="test",
|
||||
role="assistant",
|
||||
context="not a dictionary",
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
]
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
prompt = "hello world"
|
||||
messages_in = chat_history
|
||||
messages_in.add_user_message(prompt)
|
||||
messages_out = ChatHistory()
|
||||
messages_out.add_user_message(prompt)
|
||||
|
||||
expected_data_settings = {
|
||||
"data_sources": [
|
||||
{
|
||||
"type": "AzureCognitiveSearch",
|
||||
"parameters": {
|
||||
"indexName": "test_index",
|
||||
"endpoint": "https://test-endpoint-search.com",
|
||||
"key": "test_key",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings(extra_body=expected_data_settings)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
content = await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=messages_in, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
assert isinstance(content[0].items[0], TextContent)
|
||||
assert content[0].items[0].text == "test"
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_completion._prepare_chat_history_for_request(messages_out),
|
||||
stream=False,
|
||||
extra_body=expected_data_settings,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_azure_on_your_data_split_messages(
|
||||
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="test",
|
||||
role="assistant",
|
||||
context={
|
||||
"citations": {
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "test url",
|
||||
"filepath": "test filepath",
|
||||
"chunk_id": "test chunk_id",
|
||||
},
|
||||
"intent": "query used",
|
||||
},
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
]
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
prompt = "hello world"
|
||||
messages_in = chat_history
|
||||
messages_in.add_user_message(prompt)
|
||||
messages_out = ChatHistory()
|
||||
messages_out.add_user_message(prompt)
|
||||
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings()
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
content = await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=messages_in, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
messages = azure_chat_completion.split_message(content[0])
|
||||
assert len(messages) == 3
|
||||
assert isinstance(messages[0].items[0], FunctionCallContent)
|
||||
assert isinstance(messages[1].items[0], FunctionResultContent)
|
||||
assert isinstance(messages[2].items[0], TextContent)
|
||||
assert messages[2].items[0].text == "test"
|
||||
message = azure_chat_completion.split_message(messages[0])
|
||||
assert message == [messages[0]]
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_function_calling(
|
||||
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",
|
||||
function_call={"name": "test-function", "arguments": '{"key": "value"}'},
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
]
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
prompt = "hello world"
|
||||
chat_history.add_user_message(prompt)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
functions = [{"name": "test-function", "description": "test-description"}]
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings(
|
||||
function_call="test-function",
|
||||
functions=functions,
|
||||
)
|
||||
|
||||
content = await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=complete_prompt_execution_settings,
|
||||
kernel=kernel,
|
||||
)
|
||||
assert isinstance(content[0].items[0], FunctionCallContent)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_completion._prepare_chat_history_for_request(chat_history),
|
||||
stream=False,
|
||||
functions=functions,
|
||||
function_call=complete_prompt_execution_settings.function_call,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_tool_calling(
|
||||
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)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings()
|
||||
|
||||
content = await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history,
|
||||
settings=complete_prompt_execution_settings,
|
||||
kernel=kernel,
|
||||
)
|
||||
assert isinstance(content[0].items[0], FunctionCallContent)
|
||||
assert content[0].items[0].id == "test id"
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
messages=azure_chat_completion._prepare_chat_history_for_request(chat_history),
|
||||
stream=False,
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
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()
|
||||
|
||||
|
||||
@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 "
|
||||
"documentation: https://go.microsoft.com/fwlink/?linkid=2198766"
|
||||
)
|
||||
CONTENT_FILTERED_ERROR_FULL_MESSAGE = (
|
||||
"Error code: 400 - {'error': {'message': \"%s\", 'type': null, 'param': 'prompt', 'code': 'content_filter', "
|
||||
"'status': 400, 'innererror': {'code': 'ResponsibleAIPolicyViolation', 'content_filter_result': {'hate': "
|
||||
"{'filtered': True, 'severity': 'high'}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': "
|
||||
"{'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}}}"
|
||||
) % CONTENT_FILTERED_ERROR_MESSAGE
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create")
|
||||
async def test_content_filtering_raises_correct_exception(
|
||||
mock_create, kernel: Kernel, azure_openai_unit_test_env, chat_history: ChatHistory
|
||||
) -> None:
|
||||
prompt = "some prompt that would trigger the content filtering"
|
||||
chat_history.add_user_message(prompt)
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings()
|
||||
|
||||
test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
|
||||
mock_create.side_effect = openai.BadRequestError(
|
||||
CONTENT_FILTERED_ERROR_FULL_MESSAGE,
|
||||
response=Response(400, request=Request("POST", test_endpoint)),
|
||||
body={
|
||||
"message": CONTENT_FILTERED_ERROR_MESSAGE,
|
||||
"type": None,
|
||||
"param": "prompt",
|
||||
"code": "content_filter",
|
||||
"status": 400,
|
||||
"innererror": {
|
||||
"code": "ResponsibleAIPolicyViolation",
|
||||
"content_filter_result": {
|
||||
"hate": {"filtered": True, "severity": "high"},
|
||||
"self_harm": {"filtered": False, "severity": "safe"},
|
||||
"sexual": {"filtered": False, "severity": "safe"},
|
||||
"violence": {"filtered": False, "severity": "safe"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
with pytest.raises(ContentFilterAIException, match="service encountered a content error") as exc_info:
|
||||
await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
|
||||
content_filter_exc = exc_info.value
|
||||
assert content_filter_exc.param == "prompt"
|
||||
assert content_filter_exc.content_filter_result["hate"].filtered
|
||||
assert content_filter_exc.content_filter_result["hate"].severity == ContentFilterResultSeverity.HIGH
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create")
|
||||
async def test_content_filtering_without_response_code_raises_with_default_code(
|
||||
mock_create, kernel: Kernel, azure_openai_unit_test_env, chat_history: ChatHistory
|
||||
) -> None:
|
||||
prompt = "some prompt that would trigger the content filtering"
|
||||
chat_history.add_user_message(prompt)
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings()
|
||||
|
||||
test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
|
||||
mock_create.side_effect = openai.BadRequestError(
|
||||
CONTENT_FILTERED_ERROR_FULL_MESSAGE,
|
||||
response=Response(400, request=Request("POST", test_endpoint)),
|
||||
body={
|
||||
"message": CONTENT_FILTERED_ERROR_MESSAGE,
|
||||
"type": None,
|
||||
"param": "prompt",
|
||||
"code": "content_filter",
|
||||
"status": 400,
|
||||
"innererror": {
|
||||
"content_filter_result": {
|
||||
"hate": {"filtered": True, "severity": "high"},
|
||||
"self_harm": {"filtered": False, "severity": "safe"},
|
||||
"sexual": {"filtered": False, "severity": "safe"},
|
||||
"violence": {"filtered": False, "severity": "safe"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
with pytest.raises(ContentFilterAIException, match="service encountered a content error"):
|
||||
await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create")
|
||||
async def test_bad_request_non_content_filter(
|
||||
mock_create, kernel: Kernel, azure_openai_unit_test_env, chat_history: ChatHistory
|
||||
) -> None:
|
||||
prompt = "some prompt that would trigger the content filtering"
|
||||
chat_history.add_user_message(prompt)
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings()
|
||||
|
||||
test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
|
||||
mock_create.side_effect = openai.BadRequestError(
|
||||
"The request was bad.", response=Response(400, request=Request("POST", test_endpoint)), body={}
|
||||
)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
with pytest.raises(ServiceResponseException, match="service failed to complete the prompt"):
|
||||
await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create")
|
||||
async def test_no_kernel_provided_throws_error(
|
||||
mock_create, azure_openai_unit_test_env, chat_history: ChatHistory
|
||||
) -> None:
|
||||
prompt = "some prompt that would trigger the content filtering"
|
||||
chat_history.add_user_message(prompt)
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto()
|
||||
)
|
||||
|
||||
test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
|
||||
mock_create.side_effect = openai.BadRequestError(
|
||||
"The request was bad.", response=Response(400, request=Request("POST", test_endpoint)), body={}
|
||||
)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
with pytest.raises(
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
match="The kernel is required for function calls.",
|
||||
):
|
||||
await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create")
|
||||
async def test_auto_invoke_false_no_kernel_provided_throws_error(
|
||||
mock_create, azure_openai_unit_test_env, chat_history: ChatHistory
|
||||
) -> None:
|
||||
prompt = "some prompt that would trigger the content filtering"
|
||||
chat_history.add_user_message(prompt)
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings(
|
||||
function_choice_behavior=FunctionChoiceBehavior.Auto(auto_invoke=False)
|
||||
)
|
||||
|
||||
test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
|
||||
mock_create.side_effect = openai.BadRequestError(
|
||||
"The request was bad.", response=Response(400, request=Request("POST", test_endpoint)), body={}
|
||||
)
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
|
||||
with pytest.raises(
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
match="The kernel is required for function calls.",
|
||||
):
|
||||
await azure_chat_completion.get_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_streaming(
|
||||
mock_create,
|
||||
kernel: Kernel,
|
||||
azure_openai_unit_test_env,
|
||||
chat_history: ChatHistory,
|
||||
mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk],
|
||||
) -> None:
|
||||
mock_create.return_value = mock_streaming_chat_completion_response
|
||||
chat_history.add_user_message("hello world")
|
||||
complete_prompt_execution_settings = AzureChatPromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
azure_chat_completion = AzureChatCompletion()
|
||||
async for msg in azure_chat_completion.get_streaming_chat_message_contents(
|
||||
chat_history=chat_history, settings=complete_prompt_execution_settings, kernel=kernel
|
||||
):
|
||||
assert msg is not None
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
stream=True,
|
||||
messages=azure_chat_completion._prepare_chat_history_for_request(chat_history),
|
||||
# NOTE: The `stream_options={"include_usage": True}` is explicitly enforced in
|
||||
# `OpenAIChatCompletionBase._inner_get_streaming_chat_message_contents`.
|
||||
# To ensure consistency, we align the arguments here accordingly.
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncAzureOpenAI
|
||||
from openai.types import Completion
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai.services.azure_text_completion import AzureTextCompletion
|
||||
from semantic_kernel.connectors.ai.text_completion_client_base import TextCompletionClientBase
|
||||
from semantic_kernel.exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_text_completion_response() -> Mock:
|
||||
mock_response = Mock(spec=Completion)
|
||||
mock_response.id = "test_id"
|
||||
mock_response.created = "time"
|
||||
mock_response.usage = None
|
||||
mock_response.choices = []
|
||||
return mock_response
|
||||
|
||||
|
||||
def test_init(azure_openai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
azure_text_completion = AzureTextCompletion()
|
||||
|
||||
assert azure_text_completion.client is not None
|
||||
assert isinstance(azure_text_completion.client, AsyncAzureOpenAI)
|
||||
assert azure_text_completion.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_TEXT_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_text_completion, TextCompletionClientBase)
|
||||
|
||||
|
||||
def test_init_with_custom_header(azure_openai_unit_test_env) -> None:
|
||||
# Custom header for testing
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
# Test successful initialization
|
||||
azure_text_completion = AzureTextCompletion(
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
assert azure_text_completion.client is not None
|
||||
assert isinstance(azure_text_completion.client, AsyncAzureOpenAI)
|
||||
assert azure_text_completion.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_TEXT_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_text_completion, TextCompletionClientBase)
|
||||
for key, value in default_headers.items():
|
||||
assert key in azure_text_completion.client.default_headers
|
||||
assert azure_text_completion.client.default_headers[key] == value
|
||||
|
||||
|
||||
def test_azure_text_embedding_generates_no_token_with_api_key_in_env(azure_openai_unit_test_env) -> None:
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.utils.authentication.entra_id_authentication.get_entra_auth_token",
|
||||
) as mock_get_token,
|
||||
):
|
||||
azure_text_completion = AzureTextCompletion()
|
||||
|
||||
assert azure_text_completion.client is not None
|
||||
# API key is provided in env var, so the ad_token should be None
|
||||
assert mock_get_token.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_TEXT_DEPLOYMENT_NAME"]], indirect=True)
|
||||
def test_init_with_empty_deployment_name(monkeypatch, azure_openai_unit_test_env) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_TEXT_DEPLOYMENT_NAME", raising=False)
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextCompletion(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True)
|
||||
def test_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextCompletion(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True)
|
||||
def test_init_with_invalid_endpoint(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextCompletion()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True)
|
||||
def test_serialize(azure_openai_unit_test_env) -> None:
|
||||
default_headers = {"X-Test": "test"}
|
||||
|
||||
settings = {
|
||||
"deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_TEXT_DEPLOYMENT_NAME"],
|
||||
"endpoint": azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
|
||||
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
"api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
azure_text_completion = AzureTextCompletion.from_dict(settings)
|
||||
dumped_settings = azure_text_completion.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == settings["deployment_name"]
|
||||
assert settings["endpoint"] in str(dumped_settings["base_url"])
|
||||
assert settings["deployment_name"] in str(dumped_settings["base_url"])
|
||||
assert settings["api_key"] == dumped_settings["api_key"]
|
||||
assert settings["api_version"] == dumped_settings["api_version"]
|
||||
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in dumped_settings["default_headers"]
|
||||
assert dumped_settings["default_headers"][key] == value
|
||||
@@ -0,0 +1,126 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncAzureOpenAI
|
||||
from openai.resources.embeddings import AsyncEmbeddings
|
||||
|
||||
from semantic_kernel.connectors.ai.embedding_generator_base import EmbeddingGeneratorBase
|
||||
from semantic_kernel.connectors.ai.open_ai.services.azure_text_embedding import AzureTextEmbedding
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
def test_azure_text_embedding_init(azure_openai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
azure_text_embedding = AzureTextEmbedding()
|
||||
|
||||
assert azure_text_embedding.client is not None
|
||||
assert isinstance(azure_text_embedding.client, AsyncAzureOpenAI)
|
||||
assert azure_text_embedding.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_text_embedding, EmbeddingGeneratorBase)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"]], indirect=True)
|
||||
def test_azure_text_embedding_init_with_empty_deployment_name(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextEmbedding(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True)
|
||||
def test_azure_text_embedding_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextEmbedding(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True)
|
||||
def test_azure_text_embedding_init_with_invalid_endpoint(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextEmbedding()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"AZURE_OPENAI_BASE_URL": "https://test_embedding_deployment.test-base-url.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_azure_text_embedding_init_with_from_dict(azure_openai_unit_test_env) -> None:
|
||||
default_headers = {"test_header": "test_value"}
|
||||
|
||||
settings = {
|
||||
"deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"],
|
||||
"endpoint": azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
|
||||
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
"api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
azure_text_embedding = AzureTextEmbedding.from_dict(settings=settings)
|
||||
|
||||
assert azure_text_embedding.client is not None
|
||||
assert isinstance(azure_text_embedding.client, AsyncAzureOpenAI)
|
||||
assert azure_text_embedding.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_text_embedding, EmbeddingGeneratorBase)
|
||||
assert settings["deployment_name"] in str(azure_text_embedding.client.base_url)
|
||||
assert azure_text_embedding.client.api_key == azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"]
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in azure_text_embedding.client.default_headers
|
||||
assert azure_text_embedding.client.default_headers[key] == value
|
||||
|
||||
|
||||
def test_azure_text_embedding_generates_no_token_with_api_key_in_env(azure_openai_unit_test_env) -> None:
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.utils.authentication.entra_id_authentication.get_entra_auth_token",
|
||||
) as mock_get_token,
|
||||
):
|
||||
azure_text_embedding = AzureTextEmbedding()
|
||||
|
||||
assert azure_text_embedding.client is not None
|
||||
# API key is provided in env var, so the ad_token should be None
|
||||
assert mock_get_token.call_count == 0
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock)
|
||||
async def test_azure_text_embedding_calls_with_parameters(mock_create, azure_openai_unit_test_env) -> None:
|
||||
texts = ["hello world", "goodbye world"]
|
||||
embedding_dimensions = 1536
|
||||
|
||||
azure_text_embedding = AzureTextEmbedding()
|
||||
|
||||
await azure_text_embedding.generate_embeddings(texts, dimensions=embedding_dimensions)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"],
|
||||
dimensions=embedding_dimensions,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock)
|
||||
async def test_azure_text_embedding_calls_with_batches(mock_create, azure_openai_unit_test_env) -> None:
|
||||
texts = [i for i in range(0, 5)]
|
||||
|
||||
azure_text_embedding = AzureTextEmbedding()
|
||||
|
||||
await azure_text_embedding.generate_embeddings(texts, batch_size=3)
|
||||
|
||||
mock_create.assert_has_awaits(
|
||||
[
|
||||
call(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"],
|
||||
input=texts[0:3],
|
||||
),
|
||||
call(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"],
|
||||
input=texts[3:5],
|
||||
),
|
||||
],
|
||||
any_order=False,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from openai import AsyncAzureOpenAI, _legacy_response
|
||||
from openai.resources.audio.speech import AsyncSpeech
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai import AzureTextToAudio
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
def test_azure_text_to_audio_init(azure_openai_unit_test_env) -> None:
|
||||
azure_text_to_audio = AzureTextToAudio()
|
||||
|
||||
assert azure_text_to_audio.client is not None
|
||||
assert isinstance(azure_text_to_audio.client, AsyncAzureOpenAI)
|
||||
assert azure_text_to_audio.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME"]], indirect=True)
|
||||
def test_azure_text_to_audio_init_with_empty_deployment_name(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="The Azure OpenAI text to audio deployment name is required."):
|
||||
AzureTextToAudio(env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_KEY"]], indirect=True)
|
||||
def test_azure_text_to_audio_init_with_empty_api_key(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextToAudio(env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True)
|
||||
def test_azure_text_to_audio_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="Please provide an endpoint or a base_url"):
|
||||
AzureTextToAudio(env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True)
|
||||
def test_azure_text_to_audio_init_with_invalid_http_endpoint(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="Invalid settings: "):
|
||||
AzureTextToAudio()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"AZURE_OPENAI_BASE_URL": "https://test_text_to_audio_deployment.test-base-url.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_azure_text_to_audio_init_with_from_dict(azure_openai_unit_test_env) -> None:
|
||||
default_headers = {"test_header": "test_value"}
|
||||
|
||||
settings = {
|
||||
"deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME"],
|
||||
"endpoint": azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
|
||||
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
"api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
azure_text_to_audio = AzureTextToAudio.from_dict(settings=settings)
|
||||
|
||||
assert azure_text_to_audio.client is not None
|
||||
assert isinstance(azure_text_to_audio.client, AsyncAzureOpenAI)
|
||||
assert azure_text_to_audio.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME"]
|
||||
assert settings["deployment_name"] in str(azure_text_to_audio.client.base_url)
|
||||
assert azure_text_to_audio.client.api_key == azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"]
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in azure_text_to_audio.client.default_headers
|
||||
assert azure_text_to_audio.client.default_headers[key] == value
|
||||
|
||||
|
||||
@patch.object(AsyncSpeech, "create", return_value=_legacy_response.HttpxBinaryResponseContent(httpx.Response(200)))
|
||||
async def test_azure_text_to_audio_get_audio_contents(mock_speech_create, azure_openai_unit_test_env) -> None:
|
||||
openai_audio_to_text = AzureTextToAudio()
|
||||
|
||||
audio_contents = await openai_audio_to_text.get_audio_contents("Hello World!")
|
||||
assert len(audio_contents) == 1
|
||||
assert audio_contents[0].ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME"]
|
||||
@@ -0,0 +1,99 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncAzureOpenAI
|
||||
from openai.resources.images import AsyncImages
|
||||
from openai.types.image import Image
|
||||
from openai.types.images_response import ImagesResponse
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai.services.azure_text_to_image import AzureTextToImage
|
||||
from semantic_kernel.connectors.ai.text_to_image_client_base import TextToImageClientBase
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
def test_azure_text_to_image_init(azure_openai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
azure_text_to_image = AzureTextToImage()
|
||||
|
||||
assert azure_text_to_image.client is not None
|
||||
assert isinstance(azure_text_to_image.client, AsyncAzureOpenAI)
|
||||
assert azure_text_to_image.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_text_to_image, TextToImageClientBase)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME"]], indirect=True)
|
||||
def test_azure_text_to_image_init_with_empty_deployment_name(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextToImage(env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_KEY"]], indirect=True)
|
||||
def test_azure_text_to_image_init_with_empty_api_key(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextToImage(env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True)
|
||||
def test_azure_text_to_image_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextToImage(env_file_path="test.env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True)
|
||||
def test_azure_text_to_image_init_with_invalid_endpoint(azure_openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
AzureTextToImage()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[{"AZURE_OPENAI_BASE_URL": "https://test_text_to_image_deployment.test-base-url.com"}],
|
||||
indirect=True,
|
||||
)
|
||||
def test_azure_text_to_image_init_with_from_dict(azure_openai_unit_test_env) -> None:
|
||||
default_headers = {"test_header": "test_value"}
|
||||
|
||||
settings = {
|
||||
"deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME"],
|
||||
"endpoint": azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
|
||||
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
"api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
azure_text_to_image = AzureTextToImage.from_dict(settings=settings)
|
||||
|
||||
assert azure_text_to_image.client is not None
|
||||
assert isinstance(azure_text_to_image.client, AsyncAzureOpenAI)
|
||||
assert azure_text_to_image.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_text_to_image, TextToImageClientBase)
|
||||
assert settings["deployment_name"] in str(azure_text_to_image.client.base_url)
|
||||
assert azure_text_to_image.client.api_key == azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"]
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in azure_text_to_image.client.default_headers
|
||||
assert azure_text_to_image.client.default_headers[key] == value
|
||||
|
||||
|
||||
@patch.object(AsyncImages, "generate", new_callable=AsyncMock)
|
||||
async def test_azure_text_to_image_calls_with_parameters(mock_generate, azure_openai_unit_test_env) -> None:
|
||||
mock_response = ImagesResponse(created=1, data=[Image(url="abc")], usage=None)
|
||||
mock_generate.return_value = mock_response
|
||||
|
||||
prompt = "A painting of a vase with flowers"
|
||||
width = 512
|
||||
|
||||
azure_text_to_image = AzureTextToImage(
|
||||
deployment_name=azure_openai_unit_test_env["AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME"]
|
||||
)
|
||||
await azure_text_to_image.generate_image(prompt, width=width, height=width)
|
||||
|
||||
mock_generate.assert_awaited_once_with(
|
||||
prompt=prompt,
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME"],
|
||||
size=f"{width}x{width}",
|
||||
n=1,
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncClient
|
||||
from openai.resources.audio.transcriptions import AsyncTranscriptions
|
||||
from openai.types.audio import Transcription
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai import OpenAIAudioToTextExecutionSettings
|
||||
from semantic_kernel.connectors.ai.open_ai.services.open_ai_audio_to_text import OpenAIAudioToText
|
||||
from semantic_kernel.contents import AudioContent
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceInvalidRequestError
|
||||
|
||||
|
||||
def test_init(openai_unit_test_env):
|
||||
openai_audio_to_text = OpenAIAudioToText()
|
||||
|
||||
assert openai_audio_to_text.client is not None
|
||||
assert isinstance(openai_audio_to_text.client, AsyncClient)
|
||||
assert openai_audio_to_text.ai_model_id == openai_unit_test_env["OPENAI_AUDIO_TO_TEXT_MODEL_ID"]
|
||||
|
||||
|
||||
def test_init_validation_fail() -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="Failed to create OpenAI settings."):
|
||||
OpenAIAudioToText(api_key="34523", ai_model_id={"test": "dict"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_AUDIO_TO_TEXT_MODEL_ID"]], indirect=True)
|
||||
def test_init_audio_to_text_model_not_provided(openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="The OpenAI audio to text model ID is required."):
|
||||
OpenAIAudioToText(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
def test_init_with_empty_api_key(openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAIAudioToText(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
def test_init_to_from_dict(openai_unit_test_env):
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_AUDIO_TO_TEXT_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
audio_to_text = OpenAIAudioToText.from_dict(settings)
|
||||
dumped_settings = audio_to_text.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == settings["ai_model_id"]
|
||||
assert dumped_settings["api_key"] == settings["api_key"]
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(openai_unit_test_env) -> None:
|
||||
openai_audio_to_text = OpenAIAudioToText()
|
||||
assert openai_audio_to_text.get_prompt_execution_settings_class() == OpenAIAudioToTextExecutionSettings
|
||||
|
||||
|
||||
async def test_get_text_contents(openai_unit_test_env):
|
||||
audio_content = AudioContent.from_audio_file(
|
||||
os.path.join(os.path.dirname(__file__), "../../../../../", "assets/sample_audio.mp3")
|
||||
)
|
||||
|
||||
with patch.object(AsyncTranscriptions, "create", new_callable=AsyncMock) as mock_transcription_create:
|
||||
mock_transcription_create.return_value = Transcription(text="This is a test audio file.")
|
||||
|
||||
openai_audio_to_text = OpenAIAudioToText()
|
||||
|
||||
text_contents = await openai_audio_to_text.get_text_contents(audio_content)
|
||||
assert len(text_contents) == 1
|
||||
assert text_contents[0].text == "This is a test audio file."
|
||||
assert text_contents[0].ai_model_id == openai_unit_test_env["OPENAI_AUDIO_TO_TEXT_MODEL_ID"]
|
||||
|
||||
|
||||
async def test_get_text_contents_invalid_audio_content(openai_unit_test_env):
|
||||
audio_content = AudioContent()
|
||||
|
||||
openai_audio_to_text = OpenAIAudioToText()
|
||||
with pytest.raises(ServiceInvalidRequestError, match="Audio content uri must be a string to a local file."):
|
||||
await openai_audio_to_text.get_text_contents(audio_content)
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
||||
from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion
|
||||
from semantic_kernel.const import USER_AGENT
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
def test_init(openai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
open_ai_chat_completion = OpenAIChatCompletion()
|
||||
|
||||
assert open_ai_chat_completion.ai_model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert isinstance(open_ai_chat_completion, ChatCompletionClientBase)
|
||||
|
||||
|
||||
def test_init_validation_fail() -> None:
|
||||
# Test successful initialization
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAIChatCompletion(api_key="34523", ai_model_id={"test": "dict"})
|
||||
|
||||
|
||||
def test_init_ai_model_id_constructor(openai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
ai_model_id = "test_model_id"
|
||||
open_ai_chat_completion = OpenAIChatCompletion(ai_model_id=ai_model_id)
|
||||
|
||||
assert open_ai_chat_completion.ai_model_id == ai_model_id
|
||||
assert isinstance(open_ai_chat_completion, ChatCompletionClientBase)
|
||||
|
||||
|
||||
def test_init_with_default_header(openai_unit_test_env) -> None:
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
# Test successful initialization
|
||||
open_ai_chat_completion = OpenAIChatCompletion(
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
assert open_ai_chat_completion.ai_model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert isinstance(open_ai_chat_completion, ChatCompletionClientBase)
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in open_ai_chat_completion.client.default_headers
|
||||
assert open_ai_chat_completion.client.default_headers[key] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True)
|
||||
def test_init_with_empty_model_id(openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAIChatCompletion(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
def test_init_with_empty_api_key(openai_unit_test_env) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAIChatCompletion(
|
||||
ai_model_id=ai_model_id,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
def test_serialize(openai_unit_test_env) -> None:
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
open_ai_chat_completion = OpenAIChatCompletion.from_dict(settings)
|
||||
dumped_settings = open_ai_chat_completion.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in dumped_settings["default_headers"]
|
||||
assert dumped_settings["default_headers"][key] == value
|
||||
# Assert that the 'User-agent' header is not present in the dumped_settings default headers
|
||||
assert USER_AGENT not in dumped_settings["default_headers"]
|
||||
|
||||
|
||||
def test_serialize_with_org_id(openai_unit_test_env) -> None:
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"org_id": openai_unit_test_env["OPENAI_ORG_ID"],
|
||||
}
|
||||
|
||||
open_ai_chat_completion = OpenAIChatCompletion.from_dict(settings)
|
||||
dumped_settings = open_ai_chat_completion.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"]
|
||||
# Assert that the 'User-agent' header is not present in the dumped_settings default headers
|
||||
assert USER_AGENT not in dumped_settings["default_headers"]
|
||||
+1111
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncStream
|
||||
from openai.resources import AsyncCompletions
|
||||
from openai.types import Completion as TextCompletion
|
||||
from openai.types import CompletionChoice as TextCompletionChoice
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import (
|
||||
OpenAITextPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.open_ai.services.open_ai_text_completion import OpenAITextCompletion
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.text_completion_client_base import TextCompletionClientBase
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
def test_init(openai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
open_ai_text_completion = OpenAITextCompletion()
|
||||
|
||||
assert open_ai_text_completion.ai_model_id == openai_unit_test_env["OPENAI_TEXT_MODEL_ID"]
|
||||
assert isinstance(open_ai_text_completion, TextCompletionClientBase)
|
||||
|
||||
|
||||
def test_init_with_ai_model_id(openai_unit_test_env) -> None:
|
||||
# Test successful initialization
|
||||
ai_model_id = "test_model_id"
|
||||
open_ai_text_completion = OpenAITextCompletion(ai_model_id=ai_model_id)
|
||||
|
||||
assert open_ai_text_completion.ai_model_id == ai_model_id
|
||||
assert isinstance(open_ai_text_completion, TextCompletionClientBase)
|
||||
|
||||
|
||||
def test_init_with_default_header(openai_unit_test_env) -> None:
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
# Test successful initialization
|
||||
open_ai_text_completion = OpenAITextCompletion(
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
assert open_ai_text_completion.ai_model_id == openai_unit_test_env["OPENAI_TEXT_MODEL_ID"]
|
||||
assert isinstance(open_ai_text_completion, TextCompletionClientBase)
|
||||
for key, value in default_headers.items():
|
||||
assert key in open_ai_text_completion.client.default_headers
|
||||
assert open_ai_text_completion.client.default_headers[key] == value
|
||||
|
||||
|
||||
def test_init_validation_fail() -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextCompletion(api_key="34523", ai_model_id={"test": "dict"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
def test_init_with_empty_api_key(openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextCompletion(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_TEXT_MODEL_ID"]], indirect=True)
|
||||
def test_init_with_empty_model(openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextCompletion(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
def test_serialize(openai_unit_test_env) -> None:
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
open_ai_text_completion = OpenAITextCompletion.from_dict(settings)
|
||||
dumped_settings = open_ai_text_completion.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_TEXT_MODEL_ID"]
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in dumped_settings["default_headers"]
|
||||
assert dumped_settings["default_headers"][key] == value
|
||||
|
||||
|
||||
def test_serialize_def_headers_string(openai_unit_test_env) -> None:
|
||||
default_headers = '{"X-Unit-Test": "test-guid"}'
|
||||
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
open_ai_text_completion = OpenAITextCompletion.from_dict(settings)
|
||||
dumped_settings = open_ai_text_completion.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_TEXT_MODEL_ID"]
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in json.loads(default_headers).items():
|
||||
assert key in dumped_settings["default_headers"]
|
||||
assert dumped_settings["default_headers"][key] == value
|
||||
|
||||
|
||||
def test_serialize_with_org_id(openai_unit_test_env) -> None:
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"org_id": openai_unit_test_env["OPENAI_ORG_ID"],
|
||||
}
|
||||
|
||||
open_ai_text_completion = OpenAITextCompletion.from_dict(settings)
|
||||
dumped_settings = open_ai_text_completion.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_TEXT_MODEL_ID"]
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"]
|
||||
|
||||
|
||||
# region Get Text Contents
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def completion_response() -> TextCompletion:
|
||||
return TextCompletion(
|
||||
id="test",
|
||||
choices=[TextCompletionChoice(text="test", index=0, finish_reason="stop")],
|
||||
created=0,
|
||||
model="test",
|
||||
object="text_completion",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def streaming_completion_response() -> AsyncStream[TextCompletion]:
|
||||
content = TextCompletion(
|
||||
id="test",
|
||||
choices=[TextCompletionChoice(text="test", index=0, finish_reason="stop")],
|
||||
created=0,
|
||||
model="test",
|
||||
object="text_completion",
|
||||
)
|
||||
stream = MagicMock(spec=AsyncStream)
|
||||
stream.__aiter__.return_value = [content]
|
||||
return stream
|
||||
|
||||
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_tc(
|
||||
mock_create,
|
||||
openai_unit_test_env,
|
||||
completion_response,
|
||||
) -> None:
|
||||
mock_create.return_value = completion_response
|
||||
complete_prompt_execution_settings = OpenAITextPromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
openai_text_completion = OpenAITextCompletion()
|
||||
await openai_text_completion.get_text_contents(prompt="test", settings=complete_prompt_execution_settings)
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
stream=False,
|
||||
prompt="test",
|
||||
echo=False,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_tc_singular(
|
||||
mock_create,
|
||||
openai_unit_test_env,
|
||||
completion_response,
|
||||
) -> None:
|
||||
mock_create.return_value = completion_response
|
||||
complete_prompt_execution_settings = OpenAITextPromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
openai_text_completion = OpenAITextCompletion()
|
||||
await openai_text_completion.get_text_content(prompt="test", settings=complete_prompt_execution_settings)
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
stream=False,
|
||||
prompt="test",
|
||||
echo=False,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_tc_prompt_execution_settings(
|
||||
mock_create,
|
||||
openai_unit_test_env,
|
||||
completion_response,
|
||||
) -> None:
|
||||
mock_create.return_value = completion_response
|
||||
complete_prompt_execution_settings = PromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
openai_text_completion = OpenAITextCompletion()
|
||||
await openai_text_completion.get_text_contents(prompt="test", settings=complete_prompt_execution_settings)
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
stream=False,
|
||||
prompt="test",
|
||||
echo=False,
|
||||
)
|
||||
|
||||
|
||||
# region Streaming
|
||||
|
||||
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_stc(
|
||||
mock_create,
|
||||
openai_unit_test_env,
|
||||
streaming_completion_response,
|
||||
) -> None:
|
||||
mock_create.return_value = streaming_completion_response
|
||||
complete_prompt_execution_settings = OpenAITextPromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
openai_text_completion = OpenAITextCompletion()
|
||||
[
|
||||
text
|
||||
async for text in openai_text_completion.get_streaming_text_contents(
|
||||
prompt="test", settings=complete_prompt_execution_settings
|
||||
)
|
||||
]
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
stream=True,
|
||||
prompt="test",
|
||||
echo=False,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_stc_singular(
|
||||
mock_create,
|
||||
openai_unit_test_env,
|
||||
streaming_completion_response,
|
||||
) -> None:
|
||||
mock_create.return_value = streaming_completion_response
|
||||
complete_prompt_execution_settings = OpenAITextPromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
openai_text_completion = OpenAITextCompletion()
|
||||
[
|
||||
text
|
||||
async for text in openai_text_completion.get_streaming_text_content(
|
||||
prompt="test", settings=complete_prompt_execution_settings
|
||||
)
|
||||
]
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
stream=True,
|
||||
prompt="test",
|
||||
echo=False,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_stc_prompt_execution_settings(
|
||||
mock_create,
|
||||
openai_unit_test_env,
|
||||
streaming_completion_response,
|
||||
) -> None:
|
||||
mock_create.return_value = streaming_completion_response
|
||||
complete_prompt_execution_settings = PromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
openai_text_completion = OpenAITextCompletion()
|
||||
[
|
||||
text
|
||||
async for text in openai_text_completion.get_streaming_text_contents(
|
||||
prompt="test", settings=complete_prompt_execution_settings
|
||||
)
|
||||
]
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
stream=True,
|
||||
prompt="test",
|
||||
echo=False,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_stc_empty_choices(
|
||||
mock_create,
|
||||
openai_unit_test_env,
|
||||
) -> None:
|
||||
content1 = TextCompletion(
|
||||
id="test",
|
||||
choices=[],
|
||||
created=0,
|
||||
model="test",
|
||||
object="text_completion",
|
||||
)
|
||||
content2 = TextCompletion(
|
||||
id="test",
|
||||
choices=[TextCompletionChoice(text="test", index=0, finish_reason="stop")],
|
||||
created=0,
|
||||
model="test",
|
||||
object="text_completion",
|
||||
)
|
||||
stream = MagicMock(spec=AsyncStream)
|
||||
stream.__aiter__.return_value = [content1, content2]
|
||||
mock_create.return_value = stream
|
||||
complete_prompt_execution_settings = OpenAITextPromptExecutionSettings(service_id="test_service_id")
|
||||
|
||||
openai_text_completion = OpenAITextCompletion()
|
||||
results = [
|
||||
text
|
||||
async for text in openai_text_completion.get_streaming_text_contents(
|
||||
prompt="test", settings=complete_prompt_execution_settings
|
||||
)
|
||||
]
|
||||
assert len(results) == 1
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_TEXT_MODEL_ID"],
|
||||
stream=True,
|
||||
prompt="test",
|
||||
echo=False,
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncClient
|
||||
from openai.resources.embeddings import AsyncEmbeddings
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import (
|
||||
OpenAIEmbeddingPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.open_ai.services.open_ai_text_embedding import OpenAITextEmbedding
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceResponseException
|
||||
|
||||
|
||||
def test_init(openai_unit_test_env):
|
||||
openai_text_embedding = OpenAITextEmbedding()
|
||||
|
||||
assert openai_text_embedding.client is not None
|
||||
assert isinstance(openai_text_embedding.client, AsyncClient)
|
||||
assert openai_text_embedding.ai_model_id == openai_unit_test_env["OPENAI_EMBEDDING_MODEL_ID"]
|
||||
|
||||
assert openai_text_embedding.get_prompt_execution_settings_class() == OpenAIEmbeddingPromptExecutionSettings
|
||||
|
||||
|
||||
def test_init_validation_fail() -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextEmbedding(api_key="34523", ai_model_id={"test": "dict"})
|
||||
|
||||
|
||||
def test_init_to_from_dict(openai_unit_test_env):
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_EMBEDDING_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
text_embedding = OpenAITextEmbedding.from_dict(settings)
|
||||
dumped_settings = text_embedding.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == settings["ai_model_id"]
|
||||
assert dumped_settings["api_key"] == settings["api_key"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
def test_init_with_empty_api_key(openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextEmbedding(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_EMBEDDING_MODEL_ID"]], indirect=True)
|
||||
def test_init_with_no_model_id(openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextEmbedding(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock)
|
||||
async def test_embedding_calls_with_parameters(mock_create, openai_unit_test_env) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
texts = ["hello world", "goodbye world"]
|
||||
embedding_dimensions = 1536
|
||||
|
||||
openai_text_embedding = OpenAITextEmbedding(
|
||||
ai_model_id=ai_model_id,
|
||||
)
|
||||
|
||||
await openai_text_embedding.generate_embeddings(texts, dimensions=embedding_dimensions)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=ai_model_id,
|
||||
dimensions=embedding_dimensions,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock)
|
||||
async def test_embedding_calls_with_settings(mock_create, openai_unit_test_env) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
texts = ["hello world", "goodbye world"]
|
||||
settings = OpenAIEmbeddingPromptExecutionSettings(service_id="default", dimensions=1536)
|
||||
openai_text_embedding = OpenAITextEmbedding(service_id="default", ai_model_id=ai_model_id)
|
||||
|
||||
await openai_text_embedding.generate_embeddings(texts, settings=settings, timeout=10)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=ai_model_id,
|
||||
dimensions=1536,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock, side_effect=Exception)
|
||||
async def test_embedding_fail(mock_create, openai_unit_test_env) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
texts = ["hello world", "goodbye world"]
|
||||
embedding_dimensions = 1536
|
||||
|
||||
openai_text_embedding = OpenAITextEmbedding(
|
||||
ai_model_id=ai_model_id,
|
||||
)
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await openai_text_embedding.generate_embeddings(texts, dimensions=embedding_dimensions)
|
||||
|
||||
|
||||
@patch.object(AsyncEmbeddings, "create", new_callable=AsyncMock)
|
||||
async def test_embedding_pes(mock_create, openai_unit_test_env) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
texts = ["hello world", "goodbye world"]
|
||||
embedding_dimensions = 1536
|
||||
pes = PromptExecutionSettings(ai_model_id=ai_model_id, dimensions=embedding_dimensions)
|
||||
|
||||
openai_text_embedding = OpenAITextEmbedding(ai_model_id=ai_model_id)
|
||||
|
||||
await openai_text_embedding.generate_raw_embeddings(texts, pes)
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
input=texts,
|
||||
model=ai_model_id,
|
||||
dimensions=embedding_dimensions,
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from openai import AsyncClient, _legacy_response
|
||||
from openai.resources.audio.speech import AsyncSpeech
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai import OpenAITextToAudio, OpenAITextToAudioExecutionSettings
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
def test_init(openai_unit_test_env):
|
||||
openai_text_to_audio = OpenAITextToAudio()
|
||||
|
||||
assert openai_text_to_audio.client is not None
|
||||
assert isinstance(openai_text_to_audio.client, AsyncClient)
|
||||
assert openai_text_to_audio.ai_model_id == openai_unit_test_env["OPENAI_TEXT_TO_AUDIO_MODEL_ID"]
|
||||
|
||||
|
||||
def test_init_validation_fail() -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="Failed to create OpenAI settings."):
|
||||
OpenAITextToAudio(api_key="34523", ai_model_id={"test": "dict"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_TEXT_TO_AUDIO_MODEL_ID"]], indirect=True)
|
||||
def test_init_text_to_audio_model_not_provided(openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError, match="The OpenAI text to audio model ID is required."):
|
||||
OpenAITextToAudio(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
def test_init_with_empty_api_key(openai_unit_test_env) -> None:
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextToAudio(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
def test_init_to_from_dict(openai_unit_test_env):
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_TEXT_TO_AUDIO_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
audio_to_text = OpenAITextToAudio.from_dict(settings)
|
||||
dumped_settings = audio_to_text.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == settings["ai_model_id"]
|
||||
assert dumped_settings["api_key"] == settings["api_key"]
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(openai_unit_test_env) -> None:
|
||||
openai_text_to_audio = OpenAITextToAudio()
|
||||
assert openai_text_to_audio.get_prompt_execution_settings_class() == OpenAITextToAudioExecutionSettings
|
||||
|
||||
|
||||
@patch.object(AsyncSpeech, "create", return_value=_legacy_response.HttpxBinaryResponseContent(httpx.Response(200)))
|
||||
async def test_get_text_contents(mock_speech_create, openai_unit_test_env):
|
||||
openai_text_to_audio = OpenAITextToAudio()
|
||||
|
||||
audio_contents = await openai_text_to_audio.get_audio_contents("Hello World!")
|
||||
assert len(audio_contents) == 1
|
||||
assert audio_contents[0].ai_model_id == openai_unit_test_env["OPENAI_TEXT_TO_AUDIO_MODEL_ID"]
|
||||
@@ -0,0 +1,375 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import os
|
||||
import warnings
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pydantic
|
||||
import pytest
|
||||
from openai import AsyncClient
|
||||
from openai.resources.images import AsyncImages
|
||||
from openai.types.image import Image
|
||||
from openai.types.images_response import ImagesResponse
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai import OpenAITextToImage, OpenAITextToImageExecutionSettings
|
||||
from semantic_kernel.connectors.ai.open_ai.services.open_ai_text_to_image_base import OpenAITextToImageBase
|
||||
from semantic_kernel.exceptions.service_exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
ServiceInvalidRequestError,
|
||||
ServiceResponseException,
|
||||
)
|
||||
|
||||
sample_img = os.path.join(os.path.dirname(__file__), "../../../../../assets/sample_image.jpg")
|
||||
|
||||
|
||||
def test_init(openai_unit_test_env):
|
||||
"""Test that OpenAITextToImage initializes with the correct model id and client."""
|
||||
openai_text_to_image = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
|
||||
assert openai_text_to_image.client is not None
|
||||
assert isinstance(openai_text_to_image.client, AsyncClient)
|
||||
assert openai_text_to_image.ai_model_id == openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_TEXT_TO_IMAGE_MODEL_ID"]], indirect=True)
|
||||
def test_init_validation_fail(openai_unit_test_env) -> None:
|
||||
"""Test that initialization fails when required parameters are missing."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextToImage(api_key="34523", ai_model_id=None, env_file_path="test.env")
|
||||
|
||||
|
||||
def test_init_to_from_dict(openai_unit_test_env):
|
||||
"""Test to_dict and from_dict methods for correct serialization and deserialization."""
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
text_to_image = OpenAITextToImage.from_dict(settings)
|
||||
dumped_settings = text_to_image.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == settings["ai_model_id"]
|
||||
assert dumped_settings["api_key"] == settings["api_key"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
def test_init_with_empty_api_key(openai_unit_test_env) -> None:
|
||||
"""Test that initialization fails when API key is missing."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextToImage(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_TEXT_TO_IMAGE_MODEL_ID"]], indirect=True)
|
||||
def test_init_with_no_model_id(openai_unit_test_env) -> None:
|
||||
"""Test that initialization fails when model id is missing."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAITextToImage(
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
def test_prompt_execution_settings_class(openai_unit_test_env) -> None:
|
||||
"""Test that the correct prompt execution settings class is returned."""
|
||||
openai_text_to_image = OpenAITextToImage()
|
||||
assert openai_text_to_image.get_prompt_execution_settings_class() == OpenAITextToImageExecutionSettings
|
||||
|
||||
|
||||
@patch.object(AsyncImages, "generate", new_callable=AsyncMock)
|
||||
async def test_generate_calls_with_parameters(mock_generate, openai_unit_test_env) -> None:
|
||||
"""Test that generate_image calls the OpenAI API with correct parameters."""
|
||||
mock_response = ImagesResponse(created=1, data=[Image(url="abc")], usage=None)
|
||||
mock_generate.return_value = mock_response
|
||||
|
||||
ai_model_id = "test_model_id"
|
||||
prompt = "painting of flowers in vase"
|
||||
width = 512
|
||||
|
||||
openai_text_to_image = OpenAITextToImage(ai_model_id=ai_model_id)
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
await openai_text_to_image.generate_image(description=prompt, width=width, height=width)
|
||||
|
||||
mock_generate.assert_awaited_once_with(
|
||||
prompt=prompt,
|
||||
model=ai_model_id,
|
||||
size=f"{width}x{width}",
|
||||
n=1,
|
||||
)
|
||||
assert len(w) == 3
|
||||
|
||||
|
||||
@patch.object(AsyncImages, "generate", new_callable=AsyncMock, side_effect=Exception)
|
||||
async def test_generate_fail(mock_generate, openai_unit_test_env) -> None:
|
||||
"""Test that generate_image raises ServiceResponseException on API failure."""
|
||||
ai_model_id = "test_model_id"
|
||||
width = 512
|
||||
|
||||
openai_text_to_image = OpenAITextToImage(ai_model_id=ai_model_id)
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await openai_text_to_image.generate_image(description="painting of flowers in vase", width=width, height=width)
|
||||
|
||||
|
||||
async def test_generate_invalid_image_size(openai_unit_test_env) -> None:
|
||||
"""Test that invalid image size raises ServiceInvalidExecutionSettingsError."""
|
||||
ai_model_id = "test_model_id"
|
||||
width = 100
|
||||
|
||||
openai_text_to_image = OpenAITextToImage(ai_model_id=ai_model_id)
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
await openai_text_to_image.generate_image(description="painting of flowers in vase", width=width, height=width)
|
||||
|
||||
|
||||
async def test_generate_empty_description(openai_unit_test_env) -> None:
|
||||
"""Test that empty description raises ServiceInvalidExecutionSettingsError."""
|
||||
ai_model_id = "test_model_id"
|
||||
width = 100
|
||||
|
||||
openai_text_to_image = OpenAITextToImage(ai_model_id=ai_model_id)
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
await openai_text_to_image.generate_image(description="", width=width, height=width)
|
||||
|
||||
|
||||
@patch.object(AsyncImages, "generate", new_callable=AsyncMock)
|
||||
async def test_generate_no_result(mock_generate, openai_unit_test_env) -> None:
|
||||
"""Test that no result from API raises ServiceResponseException."""
|
||||
mock_generate.return_value = ImagesResponse(created=0, data=[], usage=None)
|
||||
ai_model_id = "test_model_id"
|
||||
width = 512
|
||||
|
||||
openai_text_to_image = OpenAITextToImage(ai_model_id=ai_model_id)
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await openai_text_to_image.generate_image(description="painting of flowers in vase", width=width, height=width)
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_image_edit_request", new_callable=AsyncMock)
|
||||
async def test_edit_image_with_path_success(mock_edit, openai_unit_test_env):
|
||||
"""Test editing an image using a file path returns the expected URL."""
|
||||
mock_edit.return_value = ImagesResponse(created=1, data=[Image(url="edited_url")], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
result = await service.edit_image(
|
||||
prompt="edit this image",
|
||||
image_paths=[sample_img],
|
||||
)
|
||||
assert result == ["edited_url"]
|
||||
mock_edit.assert_awaited()
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_image_edit_request", new_callable=AsyncMock)
|
||||
async def test_edit_image_with_file_success(mock_edit, openai_unit_test_env):
|
||||
"""Test editing an image using a file object returns the expected URL."""
|
||||
mock_edit.return_value = ImagesResponse(created=1, data=[Image(url="edited_url")], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
with open(sample_img, "rb") as f:
|
||||
result = await service.edit_image(
|
||||
prompt="edit this image",
|
||||
image_files=[f],
|
||||
)
|
||||
assert result == ["edited_url"]
|
||||
mock_edit.assert_awaited()
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_image_edit_request", new_callable=AsyncMock)
|
||||
async def test_edit_image_with_mask_path_and_file(mock_edit, openai_unit_test_env):
|
||||
"""Test editing an image with both mask path and mask file returns the expected URL."""
|
||||
mock_edit.return_value = ImagesResponse(created=1, data=[Image(url="edited_url")], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
# mask_path
|
||||
result = await service.edit_image(
|
||||
prompt="edit with mask",
|
||||
image_paths=[sample_img],
|
||||
mask_path=sample_img,
|
||||
)
|
||||
assert result == ["edited_url"]
|
||||
# mask_file
|
||||
with open(sample_img, "rb") as mf:
|
||||
result2 = await service.edit_image(
|
||||
prompt="edit with mask",
|
||||
image_paths=[sample_img],
|
||||
mask_file=mf,
|
||||
)
|
||||
assert result2 == ["edited_url"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_image_prompt_required(openai_unit_test_env):
|
||||
"""Test that an empty prompt raises ServiceInvalidRequestError."""
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
await service.edit_image(prompt="", image_paths=[sample_img])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_image_both_path_and_file_error(openai_unit_test_env):
|
||||
"""Test that providing both image_paths and image_files raises ServiceInvalidRequestError."""
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
with (
|
||||
open(sample_img, "rb") as f,
|
||||
pytest.raises(ServiceInvalidRequestError),
|
||||
):
|
||||
await service.edit_image(
|
||||
prompt="edit",
|
||||
image_paths=[sample_img],
|
||||
image_files=[f],
|
||||
)
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_image_edit_request", new_callable=AsyncMock)
|
||||
async def test_edit_image_no_valid_data_in_response(mock_edit, openai_unit_test_env):
|
||||
"""Test that no valid data in edit response raises ServiceResponseException."""
|
||||
mock_edit.return_value = ImagesResponse(created=1, data=[], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await service.edit_image(
|
||||
prompt="edit",
|
||||
image_paths=[sample_img],
|
||||
)
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_request", new_callable=AsyncMock)
|
||||
async def test_generate_images_with_n_parameter(mock_generate, openai_unit_test_env):
|
||||
"""Test that generate_images returns correct URLs when n parameter is set."""
|
||||
mock_generate.return_value = ImagesResponse(created=3, data=[Image(url=f"url_{i}") for i in range(3)], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
settings = OpenAITextToImageExecutionSettings(n=3)
|
||||
result = await service.generate_images("prompt", settings=settings)
|
||||
assert result == [f"url_{i}" for i in range(3)]
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_request", new_callable=AsyncMock)
|
||||
async def test_generate_images_with_output_compression_and_background(mock_generate, openai_unit_test_env):
|
||||
"""Test that output_compression and background parameters are handled correctly."""
|
||||
mock_generate.return_value = ImagesResponse(created=1, data=[Image(url="url")], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
settings = OpenAITextToImageExecutionSettings(output_compression=5, background="transparent")
|
||||
await service.generate_images("prompt", settings=settings)
|
||||
called_settings = mock_generate.call_args[0][0]
|
||||
assert called_settings.output_compression == 5
|
||||
assert called_settings.background == "transparent"
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "store_usage")
|
||||
def test_store_usage_for_images_response(mock_store_usage, openai_unit_test_env):
|
||||
"""Test that store_usage is called for ImagesResponse."""
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
response = ImagesResponse(created=1, data=[Image(url="url")], usage=None)
|
||||
service.store_usage(response)
|
||||
mock_store_usage.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_image_invalid_n_parameter():
|
||||
"""Test that invalid n parameter raises pydantic.ValidationError."""
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
OpenAITextToImageExecutionSettings(n=0)
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
OpenAITextToImageExecutionSettings(n=11)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_images_empty_prompt(openai_unit_test_env):
|
||||
"""Test that empty prompt raises ServiceInvalidRequestError."""
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
await service.generate_images("")
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_request", new_callable=AsyncMock)
|
||||
async def test_generate_images_no_result(mock_generate, openai_unit_test_env):
|
||||
"""Test that empty response data raises ServiceResponseException."""
|
||||
mock_generate.return_value = ImagesResponse(created=0, data=[], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await service.generate_images("prompt")
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_request", new_callable=AsyncMock)
|
||||
async def test_generate_images_b64_json_response(mock_generate, openai_unit_test_env):
|
||||
"""Test that generate_images returns b64_json when url is not present."""
|
||||
mock_generate.return_value = ImagesResponse(created=1, data=[Image(b64_json="base64encodeddata")], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
result = await service.generate_images("prompt")
|
||||
assert result == ["base64encodeddata"]
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_request", new_callable=AsyncMock)
|
||||
async def test_generate_images_mixed_url_and_b64_response(mock_generate, openai_unit_test_env):
|
||||
"""Test that generate_images handles mixed url and b64_json responses."""
|
||||
mock_generate.return_value = ImagesResponse(
|
||||
created=2,
|
||||
data=[Image(url="http://example.com/img1.png"), Image(b64_json="base64data")],
|
||||
usage=None,
|
||||
)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
result = await service.generate_images("prompt")
|
||||
assert result == ["http://example.com/img1.png", "base64data"]
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_request", new_callable=AsyncMock)
|
||||
async def test_generate_images_with_default_settings(mock_generate, openai_unit_test_env):
|
||||
"""Test that generate_images works when no settings are provided."""
|
||||
mock_generate.return_value = ImagesResponse(created=1, data=[Image(url="url")], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
result = await service.generate_images("a beautiful sunset")
|
||||
assert result == ["url"]
|
||||
mock_generate.assert_awaited_once()
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_request", new_callable=AsyncMock)
|
||||
async def test_generate_images_no_valid_image_data(mock_generate, openai_unit_test_env):
|
||||
"""Test that generate_images raises error when images have neither url nor b64_json."""
|
||||
mock_generate.return_value = ImagesResponse(created=1, data=[Image()], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
with pytest.raises(ServiceResponseException, match="No valid image data found"):
|
||||
await service.generate_images("prompt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_image_neither_path_nor_file(openai_unit_test_env):
|
||||
"""Test that providing neither image_paths nor image_files raises ServiceInvalidRequestError."""
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
with pytest.raises(ServiceInvalidRequestError):
|
||||
await service.edit_image(prompt="edit this")
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_image_edit_request", new_callable=AsyncMock)
|
||||
async def test_edit_image_b64_json_response(mock_edit, openai_unit_test_env):
|
||||
"""Test editing an image returns b64_json when url is not present."""
|
||||
mock_edit.return_value = ImagesResponse(created=1, data=[Image(b64_json="edited_b64")], usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
result = await service.edit_image(
|
||||
prompt="edit this image",
|
||||
image_paths=[sample_img],
|
||||
)
|
||||
assert result == ["edited_b64"]
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_image_edit_request", new_callable=AsyncMock)
|
||||
async def test_edit_image_mixed_response(mock_edit, openai_unit_test_env):
|
||||
"""Test editing images handles mixed b64_json and url responses."""
|
||||
mock_edit.return_value = ImagesResponse(
|
||||
created=2,
|
||||
data=[Image(b64_json="b64data"), Image(url="http://example.com/edited.png")],
|
||||
usage=None,
|
||||
)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
result = await service.edit_image(
|
||||
prompt="edit these images",
|
||||
image_paths=[sample_img],
|
||||
)
|
||||
assert result == ["b64data", "http://example.com/edited.png"]
|
||||
|
||||
|
||||
@patch.object(OpenAITextToImageBase, "_send_image_edit_request", new_callable=AsyncMock)
|
||||
async def test_edit_image_response_no_data_attribute(mock_edit, openai_unit_test_env):
|
||||
"""Test that edit_image raises error when response has no valid data."""
|
||||
mock_edit.return_value = ImagesResponse(created=1, data=None, usage=None)
|
||||
service = OpenAITextToImage(ai_model_id=openai_unit_test_env["OPENAI_TEXT_TO_IMAGE_MODEL_ID"])
|
||||
with pytest.raises(ServiceResponseException):
|
||||
await service.edit_image(
|
||||
prompt="edit",
|
||||
image_paths=[sample_img],
|
||||
)
|
||||
@@ -0,0 +1,384 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.azure_chat_prompt_execution_settings import (
|
||||
AzureAISearchDataSource,
|
||||
AzureAISearchDataSourceParameters,
|
||||
AzureChatPromptExecutionSettings,
|
||||
ExtraBody,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import (
|
||||
OpenAIChatPromptExecutionSettings,
|
||||
OpenAITextPromptExecutionSettings,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.connectors.azure_ai_search import AzureAISearchSettings
|
||||
from semantic_kernel.exceptions import ServiceInvalidExecutionSettingsError
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
|
||||
############################################
|
||||
# Test classes for structured output
|
||||
class ClassTest:
|
||||
attribute: str
|
||||
|
||||
|
||||
class ClassTestPydantic(KernelBaseModel):
|
||||
attribute: str
|
||||
|
||||
|
||||
############################################
|
||||
|
||||
|
||||
def test_default_openai_chat_prompt_execution_settings():
|
||||
settings = OpenAIChatPromptExecutionSettings()
|
||||
assert settings.temperature is None
|
||||
assert settings.top_p is None
|
||||
assert settings.presence_penalty is None
|
||||
assert settings.frequency_penalty is None
|
||||
assert settings.max_tokens is None
|
||||
assert settings.stop is None
|
||||
assert settings.number_of_responses is None
|
||||
assert settings.logit_bias is None
|
||||
assert settings.messages is None
|
||||
|
||||
|
||||
def test_custom_openai_chat_prompt_execution_settings():
|
||||
settings = OpenAIChatPromptExecutionSettings(
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
presence_penalty=0.5,
|
||||
frequency_penalty=0.5,
|
||||
max_tokens=128,
|
||||
stop="\n",
|
||||
number_of_responses=2,
|
||||
logit_bias={"1": 1},
|
||||
messages=[{"role": "system", "content": "Hello"}],
|
||||
)
|
||||
assert settings.temperature == 0.5
|
||||
assert settings.top_p == 0.5
|
||||
assert settings.presence_penalty == 0.5
|
||||
assert settings.frequency_penalty == 0.5
|
||||
assert settings.max_tokens == 128
|
||||
assert settings.stop == "\n"
|
||||
assert settings.number_of_responses == 2
|
||||
assert settings.logit_bias == {"1": 1}
|
||||
assert settings.messages == [{"role": "system", "content": "Hello"}]
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_from_default_completion_config():
|
||||
settings = PromptExecutionSettings(service_id="test_service")
|
||||
chat_settings = OpenAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.service_id == "test_service"
|
||||
assert chat_settings.temperature is None
|
||||
assert chat_settings.top_p is None
|
||||
assert chat_settings.presence_penalty is None
|
||||
assert chat_settings.frequency_penalty is None
|
||||
assert chat_settings.max_tokens is None
|
||||
assert chat_settings.stop is None
|
||||
assert chat_settings.number_of_responses is None
|
||||
assert chat_settings.logit_bias is None
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_from_openai_prompt_execution_settings():
|
||||
chat_settings = OpenAIChatPromptExecutionSettings(service_id="test_service", temperature=1.0)
|
||||
new_settings = OpenAIChatPromptExecutionSettings(service_id="test_2", temperature=0.0)
|
||||
chat_settings.update_from_prompt_execution_settings(new_settings)
|
||||
assert chat_settings.service_id == "test_2"
|
||||
assert chat_settings.temperature == 0.0
|
||||
|
||||
|
||||
def test_openai_text_prompt_execution_settings_validation():
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError, match="best_of must be greater than number_of_responses"):
|
||||
OpenAITextPromptExecutionSettings(best_of=1, number_of_responses=2)
|
||||
|
||||
|
||||
def test_openai_text_prompt_execution_settings_validation_manual():
|
||||
text_oai = OpenAITextPromptExecutionSettings(best_of=1, number_of_responses=1)
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError, match="best_of must be greater than number_of_responses"):
|
||||
text_oai.number_of_responses = 2
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_from_custom_completion_config():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"presence_penalty": 0.5,
|
||||
"frequency_penalty": 0.5,
|
||||
"max_tokens": 128,
|
||||
"stop": ["\n"],
|
||||
"number_of_responses": 2,
|
||||
"logprobs": 1,
|
||||
"logit_bias": {"1": 1},
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
chat_settings = OpenAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.presence_penalty == 0.5
|
||||
assert chat_settings.frequency_penalty == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
assert chat_settings.stop == ["\n"]
|
||||
assert chat_settings.number_of_responses == 2
|
||||
assert chat_settings.logit_bias == {"1": 1}
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_from_custom_completion_config_with_none():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"presence_penalty": 0.5,
|
||||
"frequency_penalty": 0.5,
|
||||
"max_tokens": 128,
|
||||
"stop": ["\n"],
|
||||
"number_of_responses": 2,
|
||||
"functions": None,
|
||||
"logit_bias": {"1": 1},
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
chat_settings = OpenAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.presence_penalty == 0.5
|
||||
assert chat_settings.frequency_penalty == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
assert chat_settings.stop == ["\n"]
|
||||
assert chat_settings.number_of_responses == 2
|
||||
assert chat_settings.logit_bias == {"1": 1}
|
||||
assert chat_settings.functions is None
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_from_custom_completion_config_with_functions():
|
||||
settings = PromptExecutionSettings(
|
||||
service_id="test_service",
|
||||
extension_data={
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.5,
|
||||
"presence_penalty": 0.5,
|
||||
"frequency_penalty": 0.5,
|
||||
"max_tokens": 128,
|
||||
"stop": ["\n"],
|
||||
"number_of_responses": 2,
|
||||
"functions": [{}],
|
||||
"function_call": "auto",
|
||||
"logit_bias": {"1": 1},
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
chat_settings = OpenAIChatPromptExecutionSettings.from_prompt_execution_settings(settings)
|
||||
assert chat_settings.temperature == 0.5
|
||||
assert chat_settings.top_p == 0.5
|
||||
assert chat_settings.presence_penalty == 0.5
|
||||
assert chat_settings.frequency_penalty == 0.5
|
||||
assert chat_settings.max_tokens == 128
|
||||
assert chat_settings.stop == ["\n"]
|
||||
assert chat_settings.number_of_responses == 2
|
||||
assert chat_settings.logit_bias == {"1": 1}
|
||||
assert chat_settings.functions == [{}]
|
||||
|
||||
|
||||
def test_create_options():
|
||||
settings = OpenAIChatPromptExecutionSettings(
|
||||
temperature=0.5,
|
||||
top_p=0.5,
|
||||
presence_penalty=0.5,
|
||||
frequency_penalty=0.5,
|
||||
max_tokens=128,
|
||||
stop=["\n"],
|
||||
number_of_responses=2,
|
||||
logit_bias={"1": 1},
|
||||
messages=[{"role": "system", "content": "Hello"}],
|
||||
function_call="auto",
|
||||
)
|
||||
options = settings.prepare_settings_dict()
|
||||
assert options["temperature"] == 0.5
|
||||
assert options["top_p"] == 0.5
|
||||
assert options["presence_penalty"] == 0.5
|
||||
assert options["frequency_penalty"] == 0.5
|
||||
assert options["max_tokens"] == 128
|
||||
assert options["stop"] == ["\n"]
|
||||
assert options["n"] == 2
|
||||
assert options["logit_bias"] == {"1": 1}
|
||||
assert not options["stream"]
|
||||
|
||||
|
||||
def test_create_options_azure_data():
|
||||
az_source = AzureAISearchDataSource(
|
||||
parameters={
|
||||
"indexName": "test-index",
|
||||
"endpoint": "test-endpoint",
|
||||
"authentication": {"type": "api_key", "key": "test-key"},
|
||||
}
|
||||
)
|
||||
extra = ExtraBody(data_sources=[az_source])
|
||||
assert extra["data_sources"] is not None
|
||||
assert extra.data_sources is not None
|
||||
settings = AzureChatPromptExecutionSettings(extra_body=extra)
|
||||
options = settings.prepare_settings_dict()
|
||||
assert options["extra_body"] == extra.model_dump(exclude_none=True, by_alias=True)
|
||||
assert options["extra_body"]["data_sources"][0]["type"] == "azure_search"
|
||||
|
||||
|
||||
def test_create_options_azure_data_from_azure_ai_settings(azure_ai_search_unit_test_env):
|
||||
az_source = AzureAISearchDataSource.from_azure_ai_search_settings(AzureAISearchSettings())
|
||||
extra = ExtraBody(data_sources=[az_source])
|
||||
assert extra["data_sources"] is not None
|
||||
settings = AzureChatPromptExecutionSettings(extra_body=extra)
|
||||
options = settings.prepare_settings_dict()
|
||||
assert options["extra_body"] == extra.model_dump(exclude_none=True, by_alias=True)
|
||||
assert options["extra_body"]["data_sources"][0]["type"] == "azure_search"
|
||||
|
||||
|
||||
def test_azure_open_ai_chat_prompt_execution_settings_with_cosmosdb_data_sources():
|
||||
input_dict = {
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
"extra_body": {
|
||||
"dataSources": [
|
||||
{
|
||||
"type": "AzureCosmosDB",
|
||||
"parameters": {
|
||||
"authentication": {
|
||||
"type": "ConnectionString",
|
||||
"connectionString": "mongodb+srv://onyourdatatest:{password}$@{cluster-name}.mongocluster.cosmos.azure.com/?tls=true&authMechanism=SCRAM-SHA-256&retrywrites=false&maxIdleTimeMS=120000",
|
||||
},
|
||||
"databaseName": "vectordb",
|
||||
"containerName": "azuredocs",
|
||||
"indexName": "azuredocindex",
|
||||
"embeddingDependency": {
|
||||
"type": "DeploymentName",
|
||||
"deploymentName": "{embedding deployment name}",
|
||||
},
|
||||
"fieldsMapping": {"vectorFields": ["contentvector"]},
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
settings = AzureChatPromptExecutionSettings.model_validate(input_dict, strict=True, from_attributes=True)
|
||||
assert settings.extra_body["dataSources"][0]["type"] == "AzureCosmosDB"
|
||||
|
||||
|
||||
def test_azure_open_ai_chat_prompt_execution_settings_with_aisearch_data_sources():
|
||||
input_dict = {
|
||||
"messages": [{"role": "system", "content": "Hello"}],
|
||||
"extra_body": {
|
||||
"dataSources": [
|
||||
{
|
||||
"type": "AzureCognitiveSearch",
|
||||
"parameters": {
|
||||
"authentication": {
|
||||
"type": "APIKey",
|
||||
"key": "****",
|
||||
},
|
||||
"endpoint": "https://****.search.windows.net/",
|
||||
"indexName": "azuredocindex",
|
||||
"queryType": "vector",
|
||||
"embeddingDependency": {
|
||||
"type": "DeploymentName",
|
||||
"deploymentName": "{embedding deployment name}",
|
||||
},
|
||||
"fieldsMapping": {"vectorFields": ["contentvector"]},
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
settings = AzureChatPromptExecutionSettings.model_validate(input_dict, strict=True, from_attributes=True)
|
||||
assert settings.extra_body["dataSources"][0]["type"] == "AzureCognitiveSearch"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"authentication",
|
||||
[
|
||||
{"type": "APIKey", "key": "test_key"},
|
||||
{"type": "api_key", "key": "test_key"},
|
||||
pytest.param({"type": "api_key"}, marks=pytest.mark.xfail),
|
||||
{"type": "SystemAssignedManagedIdentity"},
|
||||
{"type": "system_assigned_managed_identity"},
|
||||
{"type": "UserAssignedManagedIdentity", "managed_identity_resource_id": "test_id"},
|
||||
{"type": "user_assigned_managed_identity", "managed_identity_resource_id": "test_id"},
|
||||
pytest.param({"type": "user_assigned_managed_identity"}, marks=pytest.mark.xfail),
|
||||
{"type": "AccessToken", "access_token": "test_token"},
|
||||
{"type": "access_token", "access_token": "test_token"},
|
||||
pytest.param({"type": "access_token"}, marks=pytest.mark.xfail),
|
||||
pytest.param({"type": "invalid"}, marks=pytest.mark.xfail),
|
||||
],
|
||||
ids=[
|
||||
"APIKey",
|
||||
"api_key",
|
||||
"api_key_no_key",
|
||||
"SystemAssignedManagedIdentity",
|
||||
"system_assigned_managed_identity",
|
||||
"UserAssignedManagedIdentity",
|
||||
"user_assigned_managed_identity",
|
||||
"user_assigned_managed_identity_no_id",
|
||||
"AccessToken",
|
||||
"access_token",
|
||||
"access_token_no_token",
|
||||
"invalid",
|
||||
],
|
||||
)
|
||||
def test_aisearch_data_source_parameters(authentication) -> None:
|
||||
AzureAISearchDataSourceParameters(index_name="test_index", authentication=authentication)
|
||||
|
||||
|
||||
def test_azure_open_ai_chat_prompt_execution_settings_with_response_format_json():
|
||||
response_format = {"type": "json_object"}
|
||||
settings = AzureChatPromptExecutionSettings(response_format=response_format)
|
||||
options = settings.prepare_settings_dict()
|
||||
assert options["response_format"] == response_format
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_with_json_structured_output():
|
||||
settings = OpenAIChatPromptExecutionSettings()
|
||||
settings.response_format = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "math_response",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"explanation": {"type": "string"}, "output": {"type": "string"}},
|
||||
"required": ["explanation", "output"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"final_answer": {"type": "string"},
|
||||
},
|
||||
"required": ["steps", "final_answer"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
assert isinstance(settings.response_format, dict)
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_with_nonpydantic_type_structured_output():
|
||||
settings = OpenAIChatPromptExecutionSettings()
|
||||
settings.response_format = ClassTest
|
||||
assert isinstance(settings.response_format, type)
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_with_pydantic_type_structured_output():
|
||||
settings = OpenAIChatPromptExecutionSettings()
|
||||
settings.response_format = ClassTestPydantic
|
||||
assert issubclass(settings.response_format, BaseModel)
|
||||
|
||||
|
||||
def test_openai_chat_prompt_execution_settings_with_invalid_structured_output():
|
||||
settings = OpenAIChatPromptExecutionSettings()
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
settings.response_format = "invalid"
|
||||
@@ -0,0 +1,43 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.ai.completion_usage import CompletionUsage
|
||||
|
||||
|
||||
def test_completion_usage() -> None:
|
||||
"""Test the CompletionUsage class."""
|
||||
# Create a CompletionUsage object
|
||||
usage = CompletionUsage(prompt_tokens=10, completion_tokens=20)
|
||||
|
||||
# Check that the prompt tokens and completion tokens are set correctly
|
||||
assert usage.prompt_tokens == 10
|
||||
assert usage.completion_tokens == 20
|
||||
|
||||
# Create another CompletionUsage object
|
||||
other_usage = CompletionUsage(prompt_tokens=5, completion_tokens=15)
|
||||
|
||||
# Add the two CompletionUsage objects together
|
||||
total_usage = usage + other_usage
|
||||
|
||||
# Check that the total prompt tokens and completion tokens are correct
|
||||
assert total_usage.prompt_tokens == 15
|
||||
assert total_usage.completion_tokens == 35
|
||||
|
||||
|
||||
def test_completion_usage_empty() -> None:
|
||||
"""Test the CompletionUsage class with empty values."""
|
||||
# Create a CompletionUsage object with empty values
|
||||
usage = CompletionUsage()
|
||||
|
||||
# Check that the prompt tokens and completion tokens are None
|
||||
assert usage.prompt_tokens is None
|
||||
assert usage.completion_tokens is None
|
||||
|
||||
# Create another CompletionUsage object with empty values
|
||||
other_usage = CompletionUsage(prompt_tokens=5, completion_tokens=None)
|
||||
|
||||
# Add the two CompletionUsage objects together
|
||||
total_usage = usage + other_usage
|
||||
|
||||
# Check that the total prompt tokens and completion tokens are None
|
||||
assert total_usage.prompt_tokens == 5
|
||||
assert total_usage.completion_tokens == 0
|
||||
@@ -0,0 +1,232 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
from semantic_kernel.connectors.ai.function_calling_utils import _combine_filter_dicts
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import (
|
||||
DEFAULT_MAX_AUTO_INVOKE_ATTEMPTS,
|
||||
FunctionChoiceBehavior,
|
||||
FunctionChoiceType,
|
||||
)
|
||||
from semantic_kernel.exceptions import ServiceInitializationError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def function_choice_behavior():
|
||||
return FunctionChoiceBehavior()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def update_settings_callback():
|
||||
mock = Mock()
|
||||
mock.return_value = None
|
||||
return mock
|
||||
|
||||
|
||||
def test_function_choice_behavior_auto():
|
||||
behavior = FunctionChoiceBehavior.Auto(auto_invoke=True)
|
||||
assert behavior.type_ == FunctionChoiceType.AUTO
|
||||
assert behavior.maximum_auto_invoke_attempts == DEFAULT_MAX_AUTO_INVOKE_ATTEMPTS
|
||||
|
||||
|
||||
def test_function_choice_behavior_none_invoke():
|
||||
behavior = FunctionChoiceBehavior.NoneInvoke()
|
||||
assert behavior.type_ == FunctionChoiceType.NONE
|
||||
assert behavior.maximum_auto_invoke_attempts == 0
|
||||
|
||||
|
||||
def test_function_choice_behavior_required():
|
||||
expected_filters = {"included_functions": ["plugin1-func1"]}
|
||||
behavior = FunctionChoiceBehavior.Required(auto_invoke=True, filters=expected_filters)
|
||||
assert behavior.type_ == FunctionChoiceType.REQUIRED
|
||||
assert behavior.maximum_auto_invoke_attempts == 1
|
||||
assert behavior.filters == expected_filters
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("type", "max_auto_invoke_attempts"), [("auto", 5), ("none", 0), ("required", 1)])
|
||||
def test_auto_function_choice_behavior_from_dict(type: str, max_auto_invoke_attempts: int):
|
||||
data = {
|
||||
"type": type,
|
||||
"filters": {"included_functions": ["plugin1-func1", "plugin2-func2"]},
|
||||
"maximum_auto_invoke_attempts": max_auto_invoke_attempts,
|
||||
}
|
||||
behavior = FunctionChoiceBehavior.from_dict(data)
|
||||
assert behavior.type_ == FunctionChoiceType(type)
|
||||
assert behavior.filters == {"included_functions": ["plugin1-func1", "plugin2-func2"]}
|
||||
assert behavior.maximum_auto_invoke_attempts == max_auto_invoke_attempts
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("type", "max_auto_invoke_attempts"), [("auto", 5), ("none", 0), ("required", 1)])
|
||||
def test_auto_function_choice_behavior_from_dict_with_same_filters_and_functions(
|
||||
type: str, max_auto_invoke_attempts: int
|
||||
):
|
||||
data = {
|
||||
"type": type,
|
||||
"filters": {"included_functions": ["plugin1-func1", "plugin2-func2"]},
|
||||
"functions": ["plugin1-func1", "plugin2-func2"],
|
||||
"maximum_auto_invoke_attempts": max_auto_invoke_attempts,
|
||||
}
|
||||
behavior = FunctionChoiceBehavior.from_dict(data)
|
||||
assert behavior.type_ == FunctionChoiceType(type)
|
||||
assert behavior.filters == {"included_functions": ["plugin1-func1", "plugin2-func2"]}
|
||||
assert behavior.maximum_auto_invoke_attempts == max_auto_invoke_attempts
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("type", "max_auto_invoke_attempts"), [("auto", 5), ("none", 0), ("required", 1)])
|
||||
def test_auto_function_choice_behavior_from_dict_with_different_filters_and_functions(
|
||||
type: str, max_auto_invoke_attempts: int
|
||||
):
|
||||
data = {
|
||||
"type": type,
|
||||
"filters": {"included_functions": ["plugin1-func1", "plugin2-func2"]},
|
||||
"functions": ["plugin3-func3"],
|
||||
"maximum_auto_invoke_attempts": max_auto_invoke_attempts,
|
||||
}
|
||||
behavior = FunctionChoiceBehavior.from_dict(data)
|
||||
assert behavior.type_ == FunctionChoiceType(type)
|
||||
assert behavior.filters == {"included_functions": ["plugin1-func1", "plugin2-func2", "plugin3-func3"]}
|
||||
assert behavior.maximum_auto_invoke_attempts == max_auto_invoke_attempts
|
||||
|
||||
|
||||
def test_function_choice_behavior_get_set(function_choice_behavior: FunctionChoiceBehavior):
|
||||
function_choice_behavior.enable_kernel_functions = False
|
||||
assert function_choice_behavior.enable_kernel_functions is False
|
||||
function_choice_behavior.maximum_auto_invoke_attempts = 10
|
||||
assert function_choice_behavior.maximum_auto_invoke_attempts == 10
|
||||
assert function_choice_behavior.auto_invoke_kernel_functions is True
|
||||
function_choice_behavior.auto_invoke_kernel_functions = False
|
||||
assert function_choice_behavior.auto_invoke_kernel_functions is False
|
||||
assert function_choice_behavior.maximum_auto_invoke_attempts == 0
|
||||
function_choice_behavior.auto_invoke_kernel_functions = True
|
||||
assert function_choice_behavior.auto_invoke_kernel_functions is True
|
||||
assert function_choice_behavior.maximum_auto_invoke_attempts == 5
|
||||
|
||||
|
||||
def test_auto_invoke_kernel_functions():
|
||||
fcb = FunctionChoiceBehavior.Auto(auto_invoke=True)
|
||||
assert fcb is not None
|
||||
assert fcb.enable_kernel_functions is True
|
||||
assert fcb.maximum_auto_invoke_attempts == 5
|
||||
assert fcb.auto_invoke_kernel_functions is True
|
||||
|
||||
|
||||
def test_none_invoke_kernel_functions():
|
||||
fcb = FunctionChoiceBehavior.NoneInvoke()
|
||||
assert fcb is not None
|
||||
assert fcb.enable_kernel_functions is True
|
||||
assert fcb.maximum_auto_invoke_attempts == 0
|
||||
assert fcb.auto_invoke_kernel_functions is False
|
||||
|
||||
|
||||
def test_enable_functions():
|
||||
fcb = FunctionChoiceBehavior.Auto(auto_invoke=True, filters={"excluded_plugins": ["test"]})
|
||||
assert fcb is not None
|
||||
assert fcb.enable_kernel_functions is True
|
||||
assert fcb.maximum_auto_invoke_attempts == 5
|
||||
assert fcb.auto_invoke_kernel_functions is True
|
||||
assert fcb.filters == {"excluded_plugins": ["test"]}
|
||||
|
||||
|
||||
def test_required_function():
|
||||
fcb = FunctionChoiceBehavior.Required(auto_invoke=True, filters={"included_functions": ["test"]})
|
||||
assert fcb is not None
|
||||
assert fcb.enable_kernel_functions is True
|
||||
assert fcb.maximum_auto_invoke_attempts == 1
|
||||
assert fcb.auto_invoke_kernel_functions is True
|
||||
|
||||
|
||||
def test_configure_auto_invoke_kernel_functions(update_settings_callback, kernel: "Kernel"):
|
||||
fcb = FunctionChoiceBehavior.Auto(auto_invoke=True)
|
||||
fcb.configure(kernel, update_settings_callback, None)
|
||||
assert update_settings_callback.called
|
||||
|
||||
|
||||
def test_configure_auto_invoke_kernel_functions_skip(update_settings_callback, kernel: "Kernel"):
|
||||
fcb = FunctionChoiceBehavior.Auto(auto_invoke=True)
|
||||
fcb.enable_kernel_functions = False
|
||||
fcb.configure(kernel, update_settings_callback, None)
|
||||
assert not update_settings_callback.called
|
||||
|
||||
|
||||
def test_configure_none_invoke_kernel_functions(update_settings_callback, kernel: "Kernel"):
|
||||
fcb = FunctionChoiceBehavior.NoneInvoke()
|
||||
fcb.configure(kernel, update_settings_callback, None)
|
||||
assert update_settings_callback.called
|
||||
|
||||
|
||||
def test_configure_none_invoke_kernel_functions_skip(update_settings_callback, kernel: "Kernel"):
|
||||
fcb = FunctionChoiceBehavior.NoneInvoke()
|
||||
fcb.enable_kernel_functions = False
|
||||
fcb.configure(kernel, update_settings_callback, None)
|
||||
assert not update_settings_callback.called
|
||||
|
||||
|
||||
def test_configure_enable_functions(update_settings_callback, kernel: "Kernel"):
|
||||
fcb = FunctionChoiceBehavior.Auto(auto_invoke=True, filters={"excluded_plugins": ["test"]})
|
||||
fcb.configure(kernel, update_settings_callback, None)
|
||||
assert update_settings_callback.called
|
||||
|
||||
|
||||
def test_configure_enable_functions_skip(update_settings_callback, kernel: "Kernel"):
|
||||
fcb = FunctionChoiceBehavior.Auto(auto_invoke=True, filters={"excluded_plugins": ["test"]})
|
||||
fcb.enable_kernel_functions = False
|
||||
fcb.configure(kernel, update_settings_callback, None)
|
||||
assert not update_settings_callback.called
|
||||
|
||||
|
||||
def test_configure_required_function(update_settings_callback, kernel: "Kernel"):
|
||||
fcb = FunctionChoiceBehavior.Required(auto_invoke=True, filters={"included_functions": ["plugin1-func1"]})
|
||||
fcb.configure(kernel, update_settings_callback, None)
|
||||
assert update_settings_callback.called
|
||||
|
||||
|
||||
def test_configure_required_function_max_invoke_updated(update_settings_callback, kernel: "Kernel"):
|
||||
fcb = FunctionChoiceBehavior.Required(auto_invoke=True, filters={"included_functions": ["plugin1-func1"]})
|
||||
fcb.maximum_auto_invoke_attempts = 10
|
||||
fcb.configure(kernel, update_settings_callback, None)
|
||||
assert update_settings_callback.called
|
||||
assert fcb.maximum_auto_invoke_attempts == 10
|
||||
|
||||
|
||||
def test_configure_required_function_skip(update_settings_callback, kernel: "Kernel"):
|
||||
fcb = FunctionChoiceBehavior.Required(auto_invoke=True, filters={"included_functions": ["test"]})
|
||||
fcb.enable_kernel_functions = False
|
||||
fcb.configure(kernel, update_settings_callback, None)
|
||||
assert not update_settings_callback.called
|
||||
|
||||
|
||||
def test_service_initialization_error():
|
||||
dict1 = {"filter1": ["a", "b", "c"]}
|
||||
dict2 = {"filter1": "not_a_list"} # This should trigger the error
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="Values for filter key 'filter1' are not lists."):
|
||||
_combine_filter_dicts(dict1, dict2)
|
||||
|
||||
|
||||
def test_from_string_auto():
|
||||
auto = FunctionChoiceBehavior.from_string("auto")
|
||||
assert auto == FunctionChoiceBehavior.Auto()
|
||||
|
||||
|
||||
def test_from_string_none():
|
||||
none = FunctionChoiceBehavior.from_string("none")
|
||||
assert none == FunctionChoiceBehavior.NoneInvoke()
|
||||
|
||||
|
||||
def test_from_string_required():
|
||||
required = FunctionChoiceBehavior.from_string("required")
|
||||
assert required == FunctionChoiceBehavior.Required()
|
||||
|
||||
|
||||
def test_from_string_invalid():
|
||||
with pytest.raises(
|
||||
ServiceInitializationError,
|
||||
match="The specified type `invalid` is not supported. Allowed types are: `auto`, `none`, `required`.",
|
||||
):
|
||||
FunctionChoiceBehavior.from_string("invalid")
|
||||
@@ -0,0 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.ai import PromptExecutionSettings
|
||||
|
||||
|
||||
def test_init():
|
||||
settings = PromptExecutionSettings()
|
||||
assert settings.service_id is None
|
||||
assert settings.extension_data == {}
|
||||
|
||||
|
||||
def test_init_with_data():
|
||||
ext_data = {"test": "test"}
|
||||
settings = PromptExecutionSettings(service_id="test", extension_data=ext_data)
|
||||
assert settings.service_id == "test"
|
||||
assert settings.extension_data["test"] == "test"
|
||||
@@ -0,0 +1,150 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from pytest import fixture
|
||||
|
||||
|
||||
@fixture()
|
||||
def mistralai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for MistralAISettings."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"MISTRALAI_CHAT_MODEL_ID": "test_chat_model_id",
|
||||
"MISTRALAI_API_KEY": "test_api_key",
|
||||
"MISTRALAI_EMBEDDING_MODEL_ID": "test_embedding_model_id",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture()
|
||||
def anthropic_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for AnthropicSettings."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {"ANTHROPIC_CHAT_MODEL_ID": "test_chat_model_id", "ANTHROPIC_API_KEY": "test_api_key"}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture
|
||||
def azure_ai_search_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for ACA Python Unit Tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"AZURE_AI_SEARCH_API_KEY": "test-api-key",
|
||||
"AZURE_AI_SEARCH_ENDPOINT": "https://test-endpoint.com",
|
||||
"AZURE_AI_SEARCH_INDEX_NAME": "test-index-name",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture()
|
||||
def bing_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for BingConnector."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"BING_API_KEY": "test_api_key",
|
||||
"BING_CUSTOM_CONFIG": "test_org_id",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture()
|
||||
def brave_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for BraveConnector."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {"BRAVE_API_KEY": "test_api_key"}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture()
|
||||
def google_search_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for the Google Search Connector."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"GOOGLE_SEARCH_API_KEY": "test_api_key",
|
||||
"GOOGLE_SEARCH_ENGINE_ID": "test_id",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
@@ -0,0 +1,480 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession, ListToolsResult, StdioServerParameters, Tool, types
|
||||
|
||||
from semantic_kernel.connectors.mcp import MCPSsePlugin, MCPStdioPlugin, MCPStreamableHttpPlugin, MCPWebsocketPlugin
|
||||
from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel import Kernel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def list_tool_calls_with_slash() -> ListToolsResult:
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="nasa/get-astronomy-picture",
|
||||
description="func with slash",
|
||||
inputSchema={"properties": {}, "required": []},
|
||||
),
|
||||
Tool(
|
||||
name="weird\\name with spaces",
|
||||
description="func with backslash and spaces",
|
||||
inputSchema={"properties": {}, "required": []},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def list_tool_calls() -> ListToolsResult:
|
||||
return ListToolsResult(
|
||||
tools=[
|
||||
Tool(
|
||||
name="func1",
|
||||
description="func1",
|
||||
inputSchema={
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="func2",
|
||||
description="func2",
|
||||
inputSchema={},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_class,plugin_args",
|
||||
[
|
||||
(MCPSsePlugin, {"url": "http://localhost:8080/sse"}),
|
||||
(MCPStreamableHttpPlugin, {"url": "http://localhost:8080/mcp"}),
|
||||
],
|
||||
)
|
||||
async def test_mcp_plugin_session_not_initialize(plugin_class, plugin_args):
|
||||
# Test if Client can insert it's own Session
|
||||
mock_session = AsyncMock(spec=ClientSession)
|
||||
mock_session._request_id = 0
|
||||
mock_session.initialize = AsyncMock()
|
||||
async with plugin_class(name="test", session=mock_session, **plugin_args) as plugin:
|
||||
assert plugin.session is mock_session
|
||||
assert mock_session.initialize.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_class,plugin_args",
|
||||
[
|
||||
(MCPSsePlugin, {"url": "http://localhost:8080/sse"}),
|
||||
(MCPStreamableHttpPlugin, {"url": "http://localhost:8080/mcp"}),
|
||||
],
|
||||
)
|
||||
async def test_mcp_plugin_session_initialized(plugin_class, plugin_args):
|
||||
# Test if Client can insert it's own initialized Session
|
||||
mock_session = AsyncMock(spec=ClientSession)
|
||||
mock_session._request_id = 1
|
||||
mock_session.initialize = AsyncMock()
|
||||
async with plugin_class(name="test", session=mock_session, **plugin_args) as plugin:
|
||||
assert plugin.session is mock_session
|
||||
assert not mock_session.initialize.called
|
||||
|
||||
|
||||
async def test_mcp_sampling_denied_by_consent_callback():
|
||||
sampling_consent_callback = AsyncMock(return_value=False)
|
||||
plugin = MCPSsePlugin(
|
||||
name="TestMCPPlugin",
|
||||
url="http://localhost:8080/sse",
|
||||
sampling_consent_callback=sampling_consent_callback,
|
||||
)
|
||||
params = types.CreateMessageRequestParams(
|
||||
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))],
|
||||
systemPrompt="server instructions",
|
||||
maxTokens=100,
|
||||
)
|
||||
|
||||
result = await plugin.sampling_callback(MagicMock(), params)
|
||||
|
||||
sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params)
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert result.message == "Sampling denied by policy."
|
||||
|
||||
|
||||
async def test_mcp_sampling_consent_callback_error_denies_request(caplog):
|
||||
sampling_consent_callback = AsyncMock(side_effect=RuntimeError("policy failure"))
|
||||
plugin = MCPSsePlugin(
|
||||
name="TestMCPPlugin",
|
||||
url="http://localhost:8080/sse",
|
||||
sampling_consent_callback=sampling_consent_callback,
|
||||
)
|
||||
params = types.CreateMessageRequestParams(
|
||||
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))],
|
||||
systemPrompt="server instructions",
|
||||
maxTokens=100,
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="semantic_kernel.connectors.mcp"):
|
||||
result = await plugin.sampling_callback(MagicMock(), params)
|
||||
|
||||
sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params)
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert result.message == "Sampling denied by policy."
|
||||
assert "MCP sampling consent callback failed" in caplog.text
|
||||
|
||||
|
||||
async def test_mcp_sampling_without_consent_callback_denies_by_default(caplog):
|
||||
plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse")
|
||||
params = types.CreateMessageRequestParams(
|
||||
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))],
|
||||
systemPrompt="server instructions",
|
||||
maxTokens=100,
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"):
|
||||
result = await plugin.sampling_callback(MagicMock(), params)
|
||||
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert result.message == "Sampling denied: no consent callback configured."
|
||||
assert "denied because no sampling consent callback was configured" in caplog.text
|
||||
|
||||
|
||||
async def test_mcp_sampling_auto_approve_logs_warning(caplog):
|
||||
plugin = MCPSsePlugin(
|
||||
name="TestMCPPlugin",
|
||||
url="http://localhost:8080/sse",
|
||||
sampling_auto_approve=True,
|
||||
)
|
||||
params = types.CreateMessageRequestParams(
|
||||
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))],
|
||||
systemPrompt="server instructions",
|
||||
maxTokens=100,
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"):
|
||||
result = await plugin.sampling_callback(MagicMock(), params)
|
||||
|
||||
# No kernel configured, so the request is approved but then fails for lack of a chat service.
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert "auto-approved because sampling_auto_approve is enabled" in caplog.text
|
||||
|
||||
|
||||
async def test_mcp_tool_and_prompt_names_do_not_shadow_plugin_attributes():
|
||||
kernel = MagicMock()
|
||||
plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse", kernel=kernel)
|
||||
session = AsyncMock(spec=ClientSession)
|
||||
session.list_tools.return_value = ListToolsResult(
|
||||
tools=[
|
||||
Tool(name="kernel", description="reserved", inputSchema={}),
|
||||
Tool(name="safe_tool", description="safe", inputSchema={}),
|
||||
]
|
||||
)
|
||||
session.list_prompts.return_value = types.ListPromptsResult(
|
||||
prompts=[
|
||||
types.Prompt(name="session", description="reserved", arguments=[]),
|
||||
types.Prompt(name="safe_prompt", description="safe", arguments=[]),
|
||||
]
|
||||
)
|
||||
plugin.session = session
|
||||
|
||||
await plugin.load_tools()
|
||||
|
||||
assert plugin.kernel is kernel
|
||||
assert hasattr(plugin, "safe_tool")
|
||||
|
||||
await plugin.load_prompts()
|
||||
|
||||
assert plugin.session is session
|
||||
assert hasattr(plugin, "safe_prompt")
|
||||
|
||||
|
||||
async def test_mcp_tool_and_prompt_names_can_reload_existing_mcp_functions():
|
||||
plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse")
|
||||
session = AsyncMock(spec=ClientSession)
|
||||
session.list_tools.side_effect = [
|
||||
ListToolsResult(tools=[Tool(name="safe_tool", description="first tool", inputSchema={})]),
|
||||
ListToolsResult(tools=[Tool(name="safe_tool", description="second tool", inputSchema={})]),
|
||||
]
|
||||
session.list_prompts.side_effect = [
|
||||
types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="first prompt", arguments=[])]),
|
||||
types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="second prompt", arguments=[])]),
|
||||
]
|
||||
plugin.session = session
|
||||
|
||||
await plugin.load_tools()
|
||||
first_tool = plugin.safe_tool
|
||||
await plugin.load_tools()
|
||||
|
||||
assert plugin.safe_tool is not first_tool
|
||||
assert plugin.safe_tool.__kernel_function_description__ == "second tool"
|
||||
|
||||
await plugin.load_prompts()
|
||||
first_prompt = plugin.safe_prompt
|
||||
await plugin.load_prompts()
|
||||
|
||||
assert plugin.safe_prompt is not first_prompt
|
||||
assert plugin.safe_prompt.__kernel_function_description__ == "second prompt"
|
||||
|
||||
|
||||
async def test_mcp_plugin_failed_get_session():
|
||||
with (
|
||||
patch("semantic_kernel.connectors.mcp.stdio_client") as mock_stdio_client,
|
||||
):
|
||||
mock_read = MagicMock()
|
||||
mock_write = MagicMock()
|
||||
|
||||
mock_generator = MagicMock()
|
||||
# Make the mock_stdio_client return an AsyncMock for the context manager
|
||||
mock_generator.__aenter__.side_effect = Exception("Connection failed")
|
||||
mock_generator.__aexit__.return_value = (mock_read, mock_write)
|
||||
|
||||
# Make the mock_stdio_client return an AsyncMock for the context manager
|
||||
mock_stdio_client.return_value = mock_generator
|
||||
|
||||
with pytest.raises(KernelPluginInvalidConfigurationError):
|
||||
async with MCPStdioPlugin(
|
||||
name="test",
|
||||
command="echo",
|
||||
args=["Hello"],
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@patch("semantic_kernel.connectors.mcp.stdio_client")
|
||||
@patch("semantic_kernel.connectors.mcp.ClientSession")
|
||||
async def test_with_kwargs_stdio(mock_session, mock_client, list_tool_calls, kernel: "Kernel"):
|
||||
mock_read = MagicMock()
|
||||
mock_write = MagicMock()
|
||||
|
||||
mock_generator = MagicMock()
|
||||
# Make the mock_stdio_client return an AsyncMock for the context manager
|
||||
mock_generator.__aenter__.return_value = (mock_read, mock_write)
|
||||
mock_generator.__aexit__.return_value = (mock_read, mock_write)
|
||||
|
||||
# Make the mock_stdio_client return an AsyncMock for the context manager
|
||||
mock_client.return_value = mock_generator
|
||||
mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls
|
||||
async with MCPStdioPlugin(
|
||||
name="TestMCPPlugin",
|
||||
description="Test MCP Plugin",
|
||||
command="uv",
|
||||
args=["--directory", "path", "run", "file.py"],
|
||||
) as plugin:
|
||||
mock_client.assert_called_once_with(
|
||||
server=StdioServerParameters(command="uv", args=["--directory", "path", "run", "file.py"])
|
||||
)
|
||||
loaded_plugin = kernel.add_plugin(plugin)
|
||||
assert loaded_plugin is not None
|
||||
assert loaded_plugin.name == "TestMCPPlugin"
|
||||
assert loaded_plugin.description == "Test MCP Plugin"
|
||||
assert loaded_plugin.functions.get("func1") is not None
|
||||
assert loaded_plugin.functions["func1"].parameters[0].name == "name"
|
||||
assert loaded_plugin.functions["func1"].parameters[0].is_required
|
||||
assert loaded_plugin.functions.get("func2") is not None
|
||||
assert len(loaded_plugin.functions["func2"].parameters) == 0
|
||||
|
||||
|
||||
@patch("semantic_kernel.connectors.mcp.websocket_client")
|
||||
@patch("semantic_kernel.connectors.mcp.ClientSession")
|
||||
async def test_with_kwargs_websocket(mock_session, mock_client, list_tool_calls, kernel: "Kernel"):
|
||||
mock_read = MagicMock()
|
||||
mock_write = MagicMock()
|
||||
|
||||
mock_generator = MagicMock()
|
||||
# Make the mock_stdio_client return an AsyncMock for the context manager
|
||||
mock_generator.__aenter__.return_value = (mock_read, mock_write)
|
||||
mock_generator.__aexit__.return_value = (mock_read, mock_write)
|
||||
|
||||
# Make the mock_stdio_client return an AsyncMock for the context manager
|
||||
mock_client.return_value = mock_generator
|
||||
mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls
|
||||
async with MCPWebsocketPlugin(
|
||||
name="TestMCPPlugin",
|
||||
description="Test MCP Plugin",
|
||||
url="http://localhost:8080/websocket",
|
||||
) as plugin:
|
||||
mock_client.assert_called_once_with(url="http://localhost:8080/websocket")
|
||||
loaded_plugin = kernel.add_plugin(plugin)
|
||||
assert loaded_plugin is not None
|
||||
assert loaded_plugin.name == "TestMCPPlugin"
|
||||
assert loaded_plugin.description == "Test MCP Plugin"
|
||||
assert loaded_plugin.functions.get("func1") is not None
|
||||
assert loaded_plugin.functions["func1"].parameters[0].name == "name"
|
||||
assert loaded_plugin.functions["func1"].parameters[0].is_required
|
||||
assert loaded_plugin.functions.get("func2") is not None
|
||||
assert len(loaded_plugin.functions["func2"].parameters) == 0
|
||||
|
||||
|
||||
@patch("semantic_kernel.connectors.mcp.sse_client")
|
||||
@patch("semantic_kernel.connectors.mcp.ClientSession")
|
||||
async def test_with_kwargs_sse(mock_session, mock_client, list_tool_calls, kernel: "Kernel"):
|
||||
mock_read = MagicMock()
|
||||
mock_write = MagicMock()
|
||||
|
||||
mock_generator = MagicMock()
|
||||
# Make the mock_stdio_client return an AsyncMock for the context manager
|
||||
mock_generator.__aenter__.return_value = (mock_read, mock_write)
|
||||
mock_generator.__aexit__.return_value = (mock_read, mock_write)
|
||||
|
||||
# Make the mock_stdio_client return an AsyncMock for the context manager
|
||||
mock_client.return_value = mock_generator
|
||||
mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls
|
||||
async with MCPSsePlugin(
|
||||
name="TestMCPPlugin",
|
||||
description="Test MCP Plugin",
|
||||
url="http://localhost:8080/sse",
|
||||
) as plugin:
|
||||
mock_client.assert_called_once_with(url="http://localhost:8080/sse")
|
||||
loaded_plugin = kernel.add_plugin(plugin)
|
||||
assert loaded_plugin is not None
|
||||
assert loaded_plugin.name == "TestMCPPlugin"
|
||||
assert loaded_plugin.description == "Test MCP Plugin"
|
||||
assert loaded_plugin.functions.get("func1") is not None
|
||||
assert loaded_plugin.functions["func1"].parameters[0].name == "name"
|
||||
assert loaded_plugin.functions["func1"].parameters[0].is_required
|
||||
assert loaded_plugin.functions.get("func2") is not None
|
||||
assert len(loaded_plugin.functions["func2"].parameters) == 0
|
||||
|
||||
|
||||
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
|
||||
@patch("semantic_kernel.connectors.mcp.ClientSession")
|
||||
async def test_with_kwargs_streamablehttp(mock_session, mock_client, list_tool_calls, kernel: "Kernel"):
|
||||
mock_read = MagicMock()
|
||||
mock_write = MagicMock()
|
||||
mock_callback = MagicMock()
|
||||
|
||||
mock_generator = MagicMock()
|
||||
# Make the mock_streamablehttp_client return an AsyncMock for the context manager
|
||||
mock_generator.__aenter__.return_value = (mock_read, mock_write, mock_callback)
|
||||
mock_generator.__aexit__.return_value = (mock_read, mock_write, mock_callback)
|
||||
|
||||
# Make the mock_streamablehttp_client return an AsyncMock for the context manager
|
||||
mock_client.return_value = mock_generator
|
||||
mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls
|
||||
async with MCPStreamableHttpPlugin(
|
||||
name="TestMCPPlugin",
|
||||
description="Test MCP Plugin",
|
||||
url="http://localhost:8080/mcp",
|
||||
) as plugin:
|
||||
mock_client.assert_called_once_with(url="http://localhost:8080/mcp")
|
||||
loaded_plugin = kernel.add_plugin(plugin)
|
||||
assert loaded_plugin is not None
|
||||
assert loaded_plugin.name == "TestMCPPlugin"
|
||||
assert loaded_plugin.description == "Test MCP Plugin"
|
||||
assert loaded_plugin.functions.get("func1") is not None
|
||||
assert loaded_plugin.functions["func1"].parameters[0].name == "name"
|
||||
assert loaded_plugin.functions["func1"].parameters[0].is_required
|
||||
assert loaded_plugin.functions.get("func2") is not None
|
||||
assert len(loaded_plugin.functions["func2"].parameters) == 0
|
||||
|
||||
|
||||
async def test_kernel_as_mcp_server(kernel: "Kernel", decorated_native_function, custom_plugin_class):
|
||||
kernel.add_plugin(custom_plugin_class, "test")
|
||||
kernel.add_functions("test", [decorated_native_function])
|
||||
server = kernel.as_mcp_server()
|
||||
assert server is not None
|
||||
assert types.PingRequest in server.request_handlers
|
||||
assert types.ListToolsRequest in server.request_handlers
|
||||
assert types.CallToolRequest in server.request_handlers
|
||||
assert server.name == "Semantic Kernel MCP Server"
|
||||
|
||||
|
||||
@patch("semantic_kernel.connectors.mcp.sse_client")
|
||||
@patch("semantic_kernel.connectors.mcp.ClientSession")
|
||||
async def test_mcp_tool_name_normalization(mock_session, mock_client, list_tool_calls_with_slash, kernel: "Kernel"):
|
||||
"""Test that MCP tool names with illegal characters are normalized."""
|
||||
mock_read = MagicMock()
|
||||
mock_write = MagicMock()
|
||||
mock_generator = MagicMock()
|
||||
mock_generator.__aenter__.return_value = (mock_read, mock_write)
|
||||
mock_generator.__aexit__.return_value = (mock_read, mock_write)
|
||||
mock_client.return_value = mock_generator
|
||||
mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls_with_slash
|
||||
|
||||
async with MCPSsePlugin(
|
||||
name="TestMCPPlugin",
|
||||
description="Test MCP Plugin",
|
||||
url="http://localhost:8080/sse",
|
||||
) as plugin:
|
||||
loaded_plugin = kernel.add_plugin(plugin)
|
||||
# The normalized names:
|
||||
assert "nasa-get-astronomy-picture" in loaded_plugin.functions
|
||||
assert "weird-name-with-spaces" in loaded_plugin.functions
|
||||
# They should not exist with their original (invalid) names:
|
||||
assert "nasa/get-astronomy-picture" not in loaded_plugin.functions
|
||||
assert "weird\\name with spaces" not in loaded_plugin.functions
|
||||
|
||||
normalized_names = list(loaded_plugin.functions.keys())
|
||||
for name in normalized_names:
|
||||
assert re.match(r"^[A-Za-z0-9_.-]+$", name)
|
||||
|
||||
|
||||
@patch("semantic_kernel.connectors.mcp.ClientSession")
|
||||
async def test_mcp_normalization_function(mock_session, list_tool_calls_with_slash):
|
||||
"""Unit test for the normalize_mcp_name function (should exist in codebase)."""
|
||||
from semantic_kernel.connectors.mcp import _normalize_mcp_name
|
||||
|
||||
assert _normalize_mcp_name("nasa/get-astronomy-picture") == "nasa-get-astronomy-picture"
|
||||
assert _normalize_mcp_name("weird\\name with spaces") == "weird-name-with-spaces"
|
||||
assert _normalize_mcp_name("simple_name") == "simple_name"
|
||||
assert _normalize_mcp_name("Name-With.Dots_And-Hyphens") == "Name-With.Dots_And-Hyphens"
|
||||
|
||||
|
||||
async def test_excluded_function_cannot_be_called(kernel: "Kernel"):
|
||||
"""Test that excluded functions are rejected at call time, not just hidden from listing."""
|
||||
from semantic_kernel.connectors.mcp import create_mcp_server_from_kernel
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
|
||||
side_effect_called = False
|
||||
|
||||
@kernel_function(name="public_echo")
|
||||
def public_echo(message: str) -> str:
|
||||
return f"echo: {message}"
|
||||
|
||||
@kernel_function(name="secret_admin")
|
||||
def secret_admin(target: str) -> str:
|
||||
nonlocal side_effect_called
|
||||
side_effect_called = True
|
||||
return f"privileged action on {target}"
|
||||
|
||||
kernel.add_function(plugin_name="tools", function=public_echo)
|
||||
kernel.add_function(plugin_name="tools", function=secret_admin)
|
||||
|
||||
server = create_mcp_server_from_kernel(kernel, excluded_functions=["secret_admin"])
|
||||
|
||||
# Verify the server was created with handlers
|
||||
assert types.ListToolsRequest in server.request_handlers
|
||||
assert types.CallToolRequest in server.request_handlers
|
||||
|
||||
# Mock _get_cached_tool_definition to bypass SDK request context requirements
|
||||
# (normally set by a real MCP session transport)
|
||||
async def _fake_get_cached_tool_definition(tool_name):
|
||||
return None
|
||||
|
||||
server._get_cached_tool_definition = _fake_get_cached_tool_definition
|
||||
|
||||
# Build a proper CallToolRequest as the MCP SDK would send
|
||||
call_tool_request = types.CallToolRequest(
|
||||
method="tools/call",
|
||||
params=types.CallToolRequestParams(name="secret_admin", arguments={}),
|
||||
)
|
||||
|
||||
# The internal handler wraps our _call_tool; invoke via the registered handler
|
||||
handler = server.request_handlers[types.CallToolRequest]
|
||||
result = await handler(call_tool_request)
|
||||
|
||||
# The call must fail (isError=True) with the correct error message
|
||||
assert result.root.isError is True, "Calling an excluded function should return an error"
|
||||
assert any("Unknown tool" in c.text for c in result.root.content if hasattr(c, "text")), (
|
||||
f"Expected 'Unknown tool' error, got: {result.root.content}"
|
||||
)
|
||||
assert not side_effect_called, "Excluded function's side effect should not have fired"
|
||||
@@ -0,0 +1,86 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def database_name():
|
||||
"""Fixture for the database name."""
|
||||
return "test_database"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def collection_name():
|
||||
"""Fixture for the collection name."""
|
||||
return "test_collection"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def url():
|
||||
"""Fixture for the url."""
|
||||
return "https://test.cosmos.azure.com/"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def key():
|
||||
"""Fixture for the key."""
|
||||
return "test_key"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def azure_cosmos_db_mongo_db_unit_test_env(monkeypatch, url, key, database_name, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Azure Cosmos DB NoSQL unit tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"AZURE_COSMOS_DB_MONGODB_CONNECTION_STRING": url,
|
||||
"AZURE_COSMOS_DB_MONGODB_DATABASE_NAME": database_name,
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def azure_cosmos_db_no_sql_unit_test_env(monkeypatch, url, key, database_name, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Azure Cosmos DB NoSQL unit tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"AZURE_COSMOS_DB_NO_SQL_URL": url,
|
||||
"AZURE_COSMOS_DB_NO_SQL_KEY": key,
|
||||
"AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME": database_name,
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def clear_azure_cosmos_db_no_sql_env(monkeypatch):
|
||||
"""Fixture to clear the environment variables for Weaviate unit tests."""
|
||||
monkeypatch.delenv("AZURE_COSMOS_DB_NO_SQL_URL", raising=False)
|
||||
monkeypatch.delenv("AZURE_COSMOS_DB_NO_SQL_KEY", raising=False)
|
||||
monkeypatch.delenv("AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME", raising=False)
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pymongo import AsyncMongoClient
|
||||
|
||||
from semantic_kernel.connectors.azure_cosmos_db import CosmosMongoCollection
|
||||
from semantic_kernel.data.vector import VectorStoreCollectionDefinition, VectorStoreField
|
||||
from semantic_kernel.exceptions import VectorStoreInitializationException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_model() -> VectorStoreCollectionDefinition:
|
||||
return VectorStoreCollectionDefinition(
|
||||
fields=[
|
||||
VectorStoreField("key", name="id"),
|
||||
VectorStoreField("data", name="content"),
|
||||
VectorStoreField("vector", name="vector", dimensions=5),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def test_constructor_with_mongo_client_provided(mock_model) -> None:
|
||||
"""
|
||||
Test the constructor of AzureCosmosDBforMongoDBCollection when a mongo_client
|
||||
is directly provided. Expect that the class is successfully initialized
|
||||
and doesn't attempt to manage the client.
|
||||
"""
|
||||
mock_client = AsyncMock(spec=AsyncMongoClient)
|
||||
collection_name = "test_collection"
|
||||
|
||||
collection = CosmosMongoCollection(
|
||||
collection_name=collection_name,
|
||||
record_type=dict,
|
||||
mongo_client=mock_client,
|
||||
definition=mock_model,
|
||||
)
|
||||
|
||||
assert collection.mongo_client == mock_client
|
||||
assert collection.collection_name == collection_name
|
||||
assert not collection.managed_client, "Should not be managing client when provided"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_COSMOS_DB_MONGODB_CONNECTION_STRING"]], indirect=True)
|
||||
async def test_constructor_raises_exception_on_validation_error(
|
||||
azure_cosmos_db_mongo_db_unit_test_env, definition
|
||||
) -> None:
|
||||
"""
|
||||
Test that the constructor raises VectorStoreInitializationException when
|
||||
AzureCosmosDBforMongoDBSettings fails with ValidationError.
|
||||
"""
|
||||
with pytest.raises(VectorStoreInitializationException) as exc_info:
|
||||
CosmosMongoCollection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
database_name="",
|
||||
env_file_path=".no.env",
|
||||
)
|
||||
assert "The Azure CosmosDB for MongoDB connection string is required." in str(exc_info.value)
|
||||
|
||||
|
||||
async def test_ensure_collection_exists_calls_database_methods(definition) -> None:
|
||||
"""
|
||||
Test ensure_collection_exists to verify that it first creates a collection, then
|
||||
calls the appropriate command to create a vector index.
|
||||
"""
|
||||
# Setup
|
||||
mock_database = AsyncMock()
|
||||
mock_database.create_collection = AsyncMock()
|
||||
mock_database.command = AsyncMock()
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncMongoClient)
|
||||
mock_client.get_database = MagicMock(return_value=mock_database)
|
||||
|
||||
# Instantiate
|
||||
collection = CosmosMongoCollection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
mongo_client=mock_client,
|
||||
database_name="test_db",
|
||||
)
|
||||
|
||||
# Act
|
||||
await collection.ensure_collection_exists(customArg="customValue")
|
||||
|
||||
# Assert
|
||||
mock_database.create_collection.assert_awaited_once_with("test_collection", customArg="customValue")
|
||||
mock_database.command.assert_awaited()
|
||||
command_args = mock_database.command.call_args.kwargs["command"]
|
||||
|
||||
assert command_args["createIndexes"] == "test_collection"
|
||||
assert len(command_args["indexes"]) == 2, "One for the data field, one for the vector field"
|
||||
# Check the data field index
|
||||
assert command_args["indexes"][0]["name"] == "content_"
|
||||
# Check the vector field index creation
|
||||
assert command_args["indexes"][1]["name"] == "vector_"
|
||||
assert command_args["indexes"][1]["key"] == {"vector": "cosmosSearch"}
|
||||
assert command_args["indexes"][1]["cosmosSearchOptions"]["kind"] == "COS"
|
||||
assert command_args["indexes"][1]["cosmosSearchOptions"]["similarity"] is not None
|
||||
assert command_args["indexes"][1]["cosmosSearchOptions"]["dimensions"] == 5
|
||||
|
||||
|
||||
async def test_context_manager_calls_aconnect_and_close_when_managed(mock_model) -> None:
|
||||
"""
|
||||
Test that the context manager in AzureCosmosDBforMongoDBCollection calls 'aconnect' and
|
||||
'close' when the client is managed (i.e., created internally).
|
||||
"""
|
||||
mock_client = AsyncMock(spec=AsyncMongoClient)
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.connectors.azure_cosmos_db.AsyncMongoClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
collection = CosmosMongoCollection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
connection_string="mongodb://fake",
|
||||
definition=mock_model,
|
||||
)
|
||||
|
||||
# "__aenter__" should call 'aconnect'
|
||||
async with collection as c:
|
||||
mock_client.aconnect.assert_awaited_once()
|
||||
assert c is collection
|
||||
|
||||
# "__aexit__" should call 'close' if managed
|
||||
mock_client.close.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_context_manager_does_not_close_when_not_managed(mock_model) -> None:
|
||||
"""
|
||||
Test that the context manager in AzureCosmosDBforMongoDBCollection does not call 'close'
|
||||
when the client is not managed (i.e., provided externally).
|
||||
"""
|
||||
|
||||
external_client = AsyncMock(spec=AsyncMongoClient, name="external_client", value=None)
|
||||
external_client.aconnect = AsyncMock(name="aconnect")
|
||||
external_client.close = AsyncMock(name="close")
|
||||
|
||||
collection = CosmosMongoCollection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
mongo_client=external_client,
|
||||
definition=mock_model,
|
||||
)
|
||||
|
||||
# "__aenter__" scenario
|
||||
async with collection as c:
|
||||
external_client.aconnect.assert_awaited()
|
||||
assert c is collection
|
||||
|
||||
# "__aexit__" should NOT call "close" when not managed
|
||||
external_client.close.assert_not_awaited()
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.cosmos.aio import CosmosClient
|
||||
from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceNotFoundError
|
||||
|
||||
from semantic_kernel.connectors.azure_cosmos_db import (
|
||||
COSMOS_ITEM_ID_PROPERTY_NAME,
|
||||
CosmosNoSqlCollection,
|
||||
_create_default_indexing_policy_nosql,
|
||||
_create_default_vector_embedding_policy,
|
||||
)
|
||||
from semantic_kernel.data._shared import default_dynamic_filter_function
|
||||
from semantic_kernel.exceptions import (
|
||||
VectorStoreInitializationException,
|
||||
VectorStoreModelException,
|
||||
VectorStoreOperationException,
|
||||
)
|
||||
from semantic_kernel.functions import KernelParameterMetadata
|
||||
|
||||
|
||||
def test_azure_cosmos_db_no_sql_collection_init(
|
||||
clear_azure_cosmos_db_no_sql_env,
|
||||
record_type,
|
||||
database_name: str,
|
||||
collection_name: str,
|
||||
url: str,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLCollection object."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
database_name=database_name,
|
||||
url=url,
|
||||
key=key,
|
||||
)
|
||||
|
||||
assert vector_collection is not None
|
||||
assert vector_collection.database_name == database_name
|
||||
assert vector_collection.collection_name == collection_name
|
||||
assert vector_collection.cosmos_client is not None
|
||||
assert vector_collection.partition_key.path == f"/{vector_collection.definition.key_name}"
|
||||
assert vector_collection.create_database is False
|
||||
|
||||
|
||||
def test_azure_cosmos_db_no_sql_collection_init_env(
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLCollection object with environment variables."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
assert vector_collection is not None
|
||||
assert (
|
||||
vector_collection.database_name == azure_cosmos_db_no_sql_unit_test_env["AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME"]
|
||||
)
|
||||
assert vector_collection.collection_name == collection_name
|
||||
assert vector_collection.partition_key.path == f"/{vector_collection.definition.key_name}"
|
||||
assert vector_collection.create_database is False
|
||||
|
||||
|
||||
def test_azure_cosmos_db_no_sql_build_filter_escapes_apostrophes(
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test Cosmos DB filter building escapes apostrophes in string literals."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
filter_string = vector_collection._build_filter('lambda x: x.content == "O\'Reilly"')
|
||||
|
||||
assert filter_string == "c.content = 'O''Reilly'"
|
||||
|
||||
|
||||
def test_azure_cosmos_db_no_sql_build_filter_escapes_injection_payload(
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test Cosmos DB filter building keeps injection-shaped strings inside the literal."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
filter_string = vector_collection._build_filter("lambda x: x.content == \"test' OR '1'='1\"")
|
||||
|
||||
assert filter_string == "c.content = 'test'' OR ''1''=''1'"
|
||||
|
||||
|
||||
def test_azure_cosmos_db_no_sql_dynamic_filter_injection_payload_stays_literal(
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test default_dynamic_filter_function does not let user values alter Cosmos filter syntax."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
generated_filter = default_dynamic_filter_function(
|
||||
filter=None,
|
||||
parameters=[
|
||||
KernelParameterMetadata(
|
||||
name="content",
|
||||
description="Content filter",
|
||||
type="str",
|
||||
is_required=False,
|
||||
type_object=str,
|
||||
)
|
||||
],
|
||||
content="test' OR '1'='1",
|
||||
)
|
||||
|
||||
assert isinstance(generated_filter, str)
|
||||
assert vector_collection._build_filter(generated_filter) == "c.content = 'test'' OR ''1''=''1'"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_COSMOS_DB_NO_SQL_URL"]], indirect=True)
|
||||
def test_azure_cosmos_db_no_sql_collection_init_no_url(
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLCollection object with missing URL."""
|
||||
with pytest.raises(VectorStoreInitializationException):
|
||||
CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
env_file_path="fake_path",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME"]], indirect=True)
|
||||
def test_azure_cosmos_db_no_sql_collection_init_no_database_name(
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLCollection object with missing database name."""
|
||||
with pytest.raises(
|
||||
VectorStoreInitializationException, match="The name of the Azure Cosmos DB NoSQL database is missing."
|
||||
):
|
||||
CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
env_file_path="fake_path",
|
||||
)
|
||||
|
||||
|
||||
def test_azure_cosmos_db_no_sql_collection_invalid_settings(
|
||||
clear_azure_cosmos_db_no_sql_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLCollection object with invalid settings."""
|
||||
with pytest.raises(VectorStoreInitializationException):
|
||||
CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
url="invalid_url",
|
||||
)
|
||||
|
||||
|
||||
@patch.object(CosmosClient, "__init__", return_value=None)
|
||||
def test_azure_cosmos_db_no_sql_get_cosmos_client(
|
||||
mock_cosmos_client_init,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the creation of a cosmos client."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
assert vector_collection.cosmos_client is not None
|
||||
mock_cosmos_client_init.assert_called_once_with(
|
||||
str(azure_cosmos_db_no_sql_unit_test_env["AZURE_COSMOS_DB_NO_SQL_URL"]),
|
||||
credential=azure_cosmos_db_no_sql_unit_test_env["AZURE_COSMOS_DB_NO_SQL_KEY"],
|
||||
)
|
||||
|
||||
|
||||
@patch.object(CosmosClient, "__init__", return_value=None)
|
||||
def test_azure_cosmos_db_no_sql_get_cosmos_client_without_key(
|
||||
mock_cosmos_client_init,
|
||||
clear_azure_cosmos_db_no_sql_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
database_name: str,
|
||||
url: str,
|
||||
) -> None:
|
||||
"""Test the creation of a cosmos client."""
|
||||
credential = AsyncMock(spec=AsyncTokenCredential)
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
database_name=database_name,
|
||||
url=url,
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
assert vector_collection.cosmos_client is not None
|
||||
mock_cosmos_client_init.assert_called_once_with(url, credential=ANY)
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.CosmosClient", spec=True)
|
||||
async def test_azure_cosmos_db_no_sql_collection_create_database_if_not_exists(
|
||||
mock_cosmos_client,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the creation of a cosmos DB NoSQL database if it does not exist when create_database=True."""
|
||||
mock_cosmos_client.get_database_client.side_effect = CosmosResourceNotFoundError
|
||||
mock_cosmos_client.create_database = AsyncMock()
|
||||
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
cosmos_client=mock_cosmos_client,
|
||||
create_database=True,
|
||||
)
|
||||
|
||||
assert vector_collection.create_database is True
|
||||
|
||||
await vector_collection._get_database_proxy()
|
||||
|
||||
mock_cosmos_client.get_database_client.assert_called_once_with(
|
||||
azure_cosmos_db_no_sql_unit_test_env["AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME"]
|
||||
)
|
||||
mock_cosmos_client.create_database.assert_called_once_with(
|
||||
azure_cosmos_db_no_sql_unit_test_env["AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME"]
|
||||
)
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.CosmosClient", spec=True)
|
||||
async def test_azure_cosmos_db_no_sql_collection_create_database_raise_if_database_not_exists(
|
||||
mock_cosmos_client,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test _get_database_proxy raises an error if the database does not exist when create_database=False."""
|
||||
mock_cosmos_client.get_database_client.side_effect = CosmosResourceNotFoundError
|
||||
mock_cosmos_client.create_database = AsyncMock()
|
||||
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
cosmos_client=mock_cosmos_client,
|
||||
create_database=False,
|
||||
)
|
||||
|
||||
assert vector_collection.create_database is False
|
||||
|
||||
with pytest.raises(VectorStoreOperationException):
|
||||
await vector_collection._get_database_proxy()
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.CosmosClient")
|
||||
@patch("azure.cosmos.aio.DatabaseProxy")
|
||||
@pytest.mark.parametrize("index_kind, distance_function", [("flat", "cosine_similarity")])
|
||||
async def test_azure_cosmos_db_no_sql_collection_ensure_collection_exists(
|
||||
mock_database_proxy,
|
||||
mock_cosmos_client,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
):
|
||||
"""Test the creation of a cosmos DB NoSQL collection."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_database_proxy = AsyncMock(return_value=mock_database_proxy)
|
||||
|
||||
mock_database_proxy.create_container_if_not_exists = AsyncMock(return_value=None)
|
||||
|
||||
await vector_collection.ensure_collection_exists()
|
||||
|
||||
mock_database_proxy.create_container_if_not_exists.assert_called_once_with(
|
||||
id=collection_name,
|
||||
partition_key=vector_collection.partition_key,
|
||||
indexing_policy=_create_default_indexing_policy_nosql(vector_collection.definition),
|
||||
vector_embedding_policy=_create_default_vector_embedding_policy(vector_collection.definition),
|
||||
)
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.CosmosClient")
|
||||
@patch("azure.cosmos.aio.DatabaseProxy")
|
||||
@pytest.mark.parametrize("index_kind, distance_function", [("flat", "cosine_similarity")])
|
||||
async def test_azure_cosmos_db_no_sql_collection_ensure_collection_exists_allow_custom_indexing_policy(
|
||||
mock_database_proxy,
|
||||
mock_cosmos_client,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
):
|
||||
"""Test the creation of a cosmos DB NoSQL collection with a custom indexing policy."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_database_proxy = AsyncMock(return_value=mock_database_proxy)
|
||||
|
||||
mock_database_proxy.create_container_if_not_exists = AsyncMock(return_value=None)
|
||||
|
||||
await vector_collection.ensure_collection_exists(indexing_policy={"automatic": False})
|
||||
|
||||
mock_database_proxy.create_container_if_not_exists.assert_called_once_with(
|
||||
id=collection_name,
|
||||
partition_key=vector_collection.partition_key,
|
||||
indexing_policy={"automatic": False},
|
||||
vector_embedding_policy=_create_default_vector_embedding_policy(vector_collection.definition),
|
||||
)
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.CosmosClient")
|
||||
@patch("azure.cosmos.aio.DatabaseProxy")
|
||||
@pytest.mark.parametrize("index_kind, distance_function", [("flat", "cosine_similarity")])
|
||||
async def test_azure_cosmos_db_no_sql_collection_ensure_collection_exists_allow_custom_vector_embedding_policy(
|
||||
mock_database_proxy,
|
||||
mock_cosmos_client,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
):
|
||||
"""Test the creation of a cosmos DB NoSQL collection with a custom vector embedding policy."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_database_proxy = AsyncMock(return_value=mock_database_proxy)
|
||||
|
||||
mock_database_proxy.create_container_if_not_exists = AsyncMock(return_value=None)
|
||||
|
||||
await vector_collection.ensure_collection_exists(vector_embedding_policy={"vectorEmbeddings": []})
|
||||
|
||||
mock_database_proxy.create_container_if_not_exists.assert_called_once_with(
|
||||
id=collection_name,
|
||||
partition_key=vector_collection.partition_key,
|
||||
indexing_policy=_create_default_indexing_policy_nosql(vector_collection.definition),
|
||||
vector_embedding_policy={"vectorEmbeddings": []},
|
||||
)
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.CosmosClient")
|
||||
@patch("azure.cosmos.aio.DatabaseProxy")
|
||||
@pytest.mark.parametrize(
|
||||
"index_kind, distance_function, vector_property_type",
|
||||
[
|
||||
("hnsw", "cosine_similarity", "float"), # unsupported index kind
|
||||
("flat", "hamming", "float"), # unsupported distance function
|
||||
],
|
||||
)
|
||||
async def test_azure_cosmos_db_no_sql_collection_ensure_collection_exists_unsupported_vector_field_property(
|
||||
mock_database_proxy,
|
||||
mock_cosmos_client,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
):
|
||||
"""Test the creation of a cosmos DB NoSQL collection with an unsupported index kind."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_database_proxy = AsyncMock(return_value=mock_database_proxy)
|
||||
|
||||
mock_database_proxy.create_container_if_not_exists = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(VectorStoreModelException):
|
||||
await vector_collection.ensure_collection_exists()
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.DatabaseProxy")
|
||||
async def test_azure_cosmos_db_no_sql_collection_ensure_collection_deleted(
|
||||
mock_database_proxy,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the deletion of a cosmos DB NoSQL collection."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_database_proxy = AsyncMock(return_value=mock_database_proxy)
|
||||
|
||||
mock_database_proxy.delete_container = AsyncMock()
|
||||
|
||||
await vector_collection.ensure_collection_deleted()
|
||||
|
||||
mock_database_proxy.delete_container.assert_called_once_with(collection_name)
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.DatabaseProxy")
|
||||
async def test_azure_cosmos_db_no_sql_collection_ensure_collection_deleted_fail(
|
||||
mock_database_proxy,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the deletion of a cosmos DB NoSQL collection that does not exist."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_database_proxy = AsyncMock(return_value=mock_database_proxy)
|
||||
mock_database_proxy.delete_container = AsyncMock(side_effect=CosmosHttpResponseError)
|
||||
|
||||
with pytest.raises(VectorStoreOperationException, match="Container could not be deleted."):
|
||||
await vector_collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.ContainerProxy")
|
||||
async def test_azure_cosmos_db_no_sql_upsert(
|
||||
mock_container_proxy,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the upsert of a document in a cosmos DB NoSQL collection."""
|
||||
item = {"content": "test_content", "vector": [1.0, 2.0, 3.0], "id": "test_id"}
|
||||
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_container_proxy = AsyncMock(return_value=mock_container_proxy)
|
||||
|
||||
mock_container_proxy.upsert_item = AsyncMock(return_value={COSMOS_ITEM_ID_PROPERTY_NAME: item["id"]})
|
||||
|
||||
result = await vector_collection.upsert(item)
|
||||
|
||||
mock_container_proxy.upsert_item.assert_called_once_with(item)
|
||||
assert result == item["id"]
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.ContainerProxy")
|
||||
async def test_azure_cosmos_db_no_sql_upsert_without_id(
|
||||
mock_container_proxy,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type_with_key_as_key_field,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the upsert of a document in a cosmos DB NoSQL collection where the name of the key field is 'key'."""
|
||||
item = {"content": "test_content", "vector": [1.0, 2.0, 3.0], "key": "test_key"}
|
||||
item_with_id = {"content": "test_content", "vector": [1.0, 2.0, 3.0], COSMOS_ITEM_ID_PROPERTY_NAME: "test_key"}
|
||||
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type_with_key_as_key_field,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_container_proxy = AsyncMock(return_value=mock_container_proxy)
|
||||
|
||||
mock_container_proxy.upsert_item = AsyncMock(return_value={COSMOS_ITEM_ID_PROPERTY_NAME: item["key"]})
|
||||
|
||||
result = await vector_collection.upsert(item)
|
||||
|
||||
mock_container_proxy.upsert_item.assert_called_once_with(item_with_id)
|
||||
assert result == item["key"]
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.ContainerProxy")
|
||||
async def test_azure_cosmos_db_no_sql_get(
|
||||
mock_container_proxy,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the retrieval of a document from a cosmos DB NoSQL collection."""
|
||||
vector_collection: CosmosNoSqlCollection[str, record_type] = CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_container_proxy = AsyncMock(return_value=mock_container_proxy)
|
||||
|
||||
get_results = MagicMock(spec=AsyncGenerator)
|
||||
get_results.__aiter__.return_value = [{"content": "test_content", "id": "test_id"}]
|
||||
mock_container_proxy.query_items.return_value = get_results
|
||||
|
||||
record = await vector_collection.get("test_id")
|
||||
assert isinstance(record, record_type)
|
||||
assert record.content == "test_content"
|
||||
assert record.id == "test_id"
|
||||
|
||||
|
||||
@patch("azure.cosmos.aio.ContainerProxy")
|
||||
async def test_azure_cosmos_db_no_sql_get_without_id(
|
||||
mock_container_proxy,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type_with_key_as_key_field,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the retrieval of a document from a cosmos DB NoSQL collection where the name of the key field is 'key'."""
|
||||
vector_collection = CosmosNoSqlCollection(
|
||||
record_type=record_type_with_key_as_key_field,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
vector_collection._get_container_proxy = AsyncMock(return_value=mock_container_proxy)
|
||||
|
||||
get_results = MagicMock(spec=AsyncGenerator)
|
||||
get_results.__aiter__.return_value = [
|
||||
{"content": "test_content", "vector": [1.0, 2.0, 3.0], COSMOS_ITEM_ID_PROPERTY_NAME: "test_key"}
|
||||
]
|
||||
mock_container_proxy.query_items.return_value = get_results
|
||||
|
||||
record = await vector_collection.get("test_key")
|
||||
assert isinstance(record, record_type_with_key_as_key_field)
|
||||
assert record.content == "test_content"
|
||||
assert record.vector == [1.0, 2.0, 3.0]
|
||||
assert record.key == "test_key"
|
||||
|
||||
|
||||
@patch.object(CosmosClient, "close", return_value=None)
|
||||
async def test_client_is_closed(
|
||||
mock_cosmos_client_close,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
record_type,
|
||||
collection_name: str,
|
||||
) -> None:
|
||||
"""Test the close method of an AzureCosmosDBNoSQLCollection object."""
|
||||
async with CosmosNoSqlCollection(
|
||||
record_type=record_type,
|
||||
collection_name=collection_name,
|
||||
) as collection:
|
||||
assert collection.cosmos_client is not None
|
||||
|
||||
mock_cosmos_client_close.assert_called()
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from azure.cosmos.aio import CosmosClient
|
||||
|
||||
from semantic_kernel.connectors.azure_cosmos_db import CosmosNoSqlCollection, CosmosNoSqlStore
|
||||
from semantic_kernel.exceptions import VectorStoreInitializationException
|
||||
|
||||
|
||||
def test_azure_cosmos_db_no_sql_store_init(
|
||||
clear_azure_cosmos_db_no_sql_env,
|
||||
database_name: str,
|
||||
url: str,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLStore object."""
|
||||
vector_store = CosmosNoSqlStore(url=url, key=key, database_name=database_name)
|
||||
|
||||
assert vector_store is not None
|
||||
assert vector_store.database_name == database_name
|
||||
assert vector_store.cosmos_client is not None
|
||||
assert vector_store.create_database is False
|
||||
|
||||
|
||||
def test_azure_cosmos_db_no_sql_store_init_env(azure_cosmos_db_no_sql_unit_test_env) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLStore object with environment variables."""
|
||||
vector_store = CosmosNoSqlStore()
|
||||
|
||||
assert vector_store is not None
|
||||
assert vector_store.database_name == azure_cosmos_db_no_sql_unit_test_env["AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME"]
|
||||
assert vector_store.cosmos_client is not None
|
||||
assert vector_store.create_database is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_COSMOS_DB_NO_SQL_URL"]], indirect=True)
|
||||
def test_azure_cosmos_db_no_sql_store_init_no_url(
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLStore object with missing URL."""
|
||||
with pytest.raises(VectorStoreInitializationException):
|
||||
CosmosNoSqlStore(env_file_path="fake_path")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME"]], indirect=True)
|
||||
def test_azure_cosmos_db_no_sql_store_init_no_database_name(
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLStore object with missing database name."""
|
||||
with pytest.raises(
|
||||
VectorStoreInitializationException, match="The name of the Azure Cosmos DB NoSQL database is missing."
|
||||
):
|
||||
CosmosNoSqlStore(env_file_path="fake_path")
|
||||
|
||||
|
||||
def test_azure_cosmos_db_no_sql_store_invalid_settings(
|
||||
clear_azure_cosmos_db_no_sql_env,
|
||||
) -> None:
|
||||
"""Test the initialization of an AzureCosmosDBNoSQLStore object with invalid settings."""
|
||||
with pytest.raises(VectorStoreInitializationException, match="Failed to validate Azure Cosmos DB NoSQL settings."):
|
||||
CosmosNoSqlStore(url="invalid_url")
|
||||
|
||||
|
||||
@patch.object(CosmosNoSqlCollection, "__init__", return_value=None)
|
||||
def test_azure_cosmos_db_no_sql_store_get_collection(
|
||||
mock_azure_cosmos_db_no_sql_collection_init,
|
||||
azure_cosmos_db_no_sql_unit_test_env,
|
||||
collection_name: str,
|
||||
record_type,
|
||||
) -> None:
|
||||
"""Test the get_collection method of an AzureCosmosDBNoSQLStore object."""
|
||||
vector_store = CosmosNoSqlStore()
|
||||
|
||||
collection = vector_store.get_collection(collection_name=collection_name, record_type=record_type)
|
||||
|
||||
assert collection is not None
|
||||
mock_azure_cosmos_db_no_sql_collection_init.assert_called_once_with(
|
||||
record_type=record_type,
|
||||
definition=None,
|
||||
collection_name=collection_name,
|
||||
database_name=azure_cosmos_db_no_sql_unit_test_env["AZURE_COSMOS_DB_NO_SQL_DATABASE_NAME"],
|
||||
embedding_generator=None,
|
||||
url=azure_cosmos_db_no_sql_unit_test_env["AZURE_COSMOS_DB_NO_SQL_URL"],
|
||||
key=azure_cosmos_db_no_sql_unit_test_env["AZURE_COSMOS_DB_NO_SQL_KEY"],
|
||||
cosmos_client=vector_store.cosmos_client,
|
||||
partition_key=None,
|
||||
create_database=vector_store.create_database,
|
||||
env_file_path=None,
|
||||
env_file_encoding=None,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(CosmosClient, "close", return_value=None)
|
||||
async def test_client_is_closed(mock_cosmos_client_close, azure_cosmos_db_no_sql_unit_test_env) -> None:
|
||||
"""Test the close method of an AzureCosmosDBNoSQLStore object."""
|
||||
async with CosmosNoSqlStore() as vector_store:
|
||||
assert vector_store.cosmos_client is not None
|
||||
|
||||
mock_cosmos_client_close.assert_called()
|
||||
@@ -0,0 +1,308 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from _pytest.mark.structures import ParameterSet
|
||||
from pytest import fixture, param
|
||||
|
||||
from semantic_kernel.exceptions.vector_store_exceptions import VectorStoreOperationException
|
||||
|
||||
|
||||
@fixture()
|
||||
def mongodb_atlas_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for MongoDB Atlas Unit Tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {"MONGODB_ATLAS_CONNECTION_STRING": "mongodb://test", "MONGODB_ATLAS_DATABASE_NAME": "test-database"}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture
|
||||
def postgres_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Postgres connector."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {"POSTGRES_CONNECTION_STRING": "host=localhost port=5432 dbname=postgres user=testuser password=example"}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture
|
||||
def qdrant_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for QdrantConnector."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {"QDRANT_LOCATION": "http://localhost:6333"}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture
|
||||
def redis_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Redis."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {"REDIS_CONNECTION_STRING": "redis://localhost:6379"}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture
|
||||
def pinecone_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Pinecone."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {"PINECONE_API_KEY": "test_key"}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture
|
||||
def sql_server_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for SQL Server."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"SQL_SERVER_CONNECTION_STRING": "Driver={ODBC Driver 18 for SQL Server};Server=localhost;Database=testdb;User Id=testuser;Password=example;" # noqa: E501
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
def filter_lambda_list(store: str) -> list[ParameterSet]:
|
||||
"""Fixture to provide a list of filter lambdas for testing."""
|
||||
sets = [
|
||||
(
|
||||
lambda x: x.content == "value",
|
||||
{
|
||||
"ai_search": "content eq 'value'",
|
||||
},
|
||||
"equal with string",
|
||||
),
|
||||
(
|
||||
lambda x: x.id == 0,
|
||||
{
|
||||
"ai_search": "id eq 0",
|
||||
},
|
||||
"equal with int",
|
||||
),
|
||||
(
|
||||
lambda x: x.content != "value",
|
||||
{
|
||||
"ai_search": "content ne 'value'",
|
||||
},
|
||||
"not equal",
|
||||
),
|
||||
(
|
||||
lambda x: x.id > 0,
|
||||
{
|
||||
"ai_search": "id gt 0",
|
||||
},
|
||||
"greater than",
|
||||
),
|
||||
(
|
||||
lambda x: x.id >= 0,
|
||||
{
|
||||
"ai_search": "id ge 0",
|
||||
},
|
||||
"greater than or equal",
|
||||
),
|
||||
(
|
||||
lambda x: x.id == +0,
|
||||
{
|
||||
"ai_search": "id eq +0",
|
||||
},
|
||||
"equal with explicit positive",
|
||||
),
|
||||
(
|
||||
lambda x: x.id < 0,
|
||||
{
|
||||
"ai_search": "id lt 0",
|
||||
},
|
||||
"less than",
|
||||
),
|
||||
(
|
||||
lambda x: x.id <= 0,
|
||||
{
|
||||
"ai_search": "id le 0",
|
||||
},
|
||||
"less than or equal",
|
||||
),
|
||||
(
|
||||
lambda x: -10 <= x.id <= 0,
|
||||
{
|
||||
"ai_search": "(-10 le id and id le 0)",
|
||||
},
|
||||
"between inclusive",
|
||||
),
|
||||
(
|
||||
lambda x: -10 < x.id < 0,
|
||||
{
|
||||
"ai_search": "(-10 lt id and id lt 0)",
|
||||
},
|
||||
"between exclusive",
|
||||
),
|
||||
(
|
||||
lambda x: x.content == "value" and x.id == 0,
|
||||
{
|
||||
"ai_search": "(content eq 'value' and id eq 0)",
|
||||
},
|
||||
"and",
|
||||
),
|
||||
(
|
||||
lambda x: x.content == "value" or x.id == 0,
|
||||
{
|
||||
"ai_search": "(content eq 'value' or id eq 0)",
|
||||
},
|
||||
"or",
|
||||
),
|
||||
(
|
||||
lambda x: not x.content,
|
||||
{
|
||||
"ai_search": "not content",
|
||||
},
|
||||
"not with truthy",
|
||||
),
|
||||
(
|
||||
lambda x: not (x.content == "value"), # noqa: SIM201
|
||||
{
|
||||
"ai_search": "not content eq 'value'",
|
||||
},
|
||||
"not with equal",
|
||||
),
|
||||
(
|
||||
lambda x: not (x.content != "value"), # noqa: SIM202
|
||||
{
|
||||
"ai_search": "not content ne 'value'",
|
||||
},
|
||||
"not with not equal",
|
||||
),
|
||||
(
|
||||
lambda x: "value" in x.content,
|
||||
{
|
||||
"ai_search": "search.ismatch('value', 'content')",
|
||||
},
|
||||
"contains",
|
||||
),
|
||||
(
|
||||
lambda x: "value" not in x.content,
|
||||
{
|
||||
"ai_search": "not search.ismatch('value', 'content')",
|
||||
},
|
||||
"not contains",
|
||||
),
|
||||
(
|
||||
lambda x: (x.id > 0 and x.id < 3) or (x.id > 7 and x.id < 10),
|
||||
{
|
||||
"ai_search": "((id gt 0 and id lt 3) or (id gt 7 and id lt 10))",
|
||||
},
|
||||
"complex",
|
||||
),
|
||||
(
|
||||
lambda x: x.unknown_field == "value",
|
||||
{
|
||||
"ai_search": VectorStoreOperationException,
|
||||
},
|
||||
"fail unknown field",
|
||||
),
|
||||
(
|
||||
lambda x: any(x == "a" for x in x.content),
|
||||
{
|
||||
"ai_search": NotImplementedError,
|
||||
},
|
||||
"comprehension",
|
||||
),
|
||||
(
|
||||
lambda x: ~x.id,
|
||||
{
|
||||
"ai_search": NotImplementedError,
|
||||
},
|
||||
"invert",
|
||||
),
|
||||
(
|
||||
lambda x: constant, # noqa: F821
|
||||
{
|
||||
"ai_search": NotImplementedError,
|
||||
},
|
||||
"constant",
|
||||
),
|
||||
(
|
||||
lambda x: x.content.city == "Seattle",
|
||||
{
|
||||
"ai_search": "content/city eq 'Seattle'",
|
||||
},
|
||||
"nested property",
|
||||
),
|
||||
]
|
||||
return [param(s[0], s[1][store], id=s[2]) for s in sets if store in s[1]]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pymongo import AsyncMongoClient
|
||||
from pymongo.asynchronous.collection import AsyncCollection
|
||||
from pymongo.asynchronous.database import AsyncDatabase
|
||||
|
||||
BASE_PATH = "pymongo.asynchronous.mongo_client.AsyncMongoClient"
|
||||
DATABASE_PATH = "pymongo.asynchronous.database.AsyncDatabase"
|
||||
COLLECTION_PATH = "pymongo.asynchronous.collection.AsyncCollection"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_mongo_client():
|
||||
with patch(BASE_PATH, spec=AsyncMongoClient) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_get_database(mock_mongo_client):
|
||||
with (
|
||||
patch(DATABASE_PATH, spec=AsyncDatabase) as mock_db,
|
||||
patch.object(mock_mongo_client, "get_database", new_callable=lambda: mock_db) as mock,
|
||||
):
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_get_collection(mock_get_database):
|
||||
with (
|
||||
patch(COLLECTION_PATH, spec=AsyncCollection) as mock_collection,
|
||||
patch.object(mock_get_database, "get_collection", new_callable=lambda: mock_collection) as mock,
|
||||
):
|
||||
yield mock
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pymongo import AsyncMongoClient
|
||||
from pymongo.asynchronous.cursor import AsyncCursor
|
||||
from pymongo.results import UpdateResult
|
||||
from pytest import mark, raises
|
||||
|
||||
from semantic_kernel.connectors.mongodb import DEFAULT_DB_NAME, DEFAULT_SEARCH_INDEX_NAME, MongoDBAtlasCollection
|
||||
from semantic_kernel.exceptions.vector_store_exceptions import VectorStoreInitializationException
|
||||
|
||||
|
||||
def test_mongodb_atlas_collection_initialization(mongodb_atlas_unit_test_env, definition, mock_mongo_client):
|
||||
collection = MongoDBAtlasCollection(
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
collection_name="test_collection",
|
||||
mongo_client=mock_mongo_client,
|
||||
)
|
||||
assert collection.mongo_client is not None
|
||||
assert isinstance(collection.mongo_client, AsyncMongoClient)
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["MONGODB_ATLAS_CONNECTION_STRING"]], indirect=True)
|
||||
def test_mongodb_atlas_collection_initialization_fail(mongodb_atlas_unit_test_env, definition):
|
||||
with raises(VectorStoreInitializationException):
|
||||
MongoDBAtlasCollection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
)
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["MONGODB_ATLAS_DATABASE_NAME", "MONGODB_ATLAS_INDEX_NAME"]], indirect=True)
|
||||
def test_mongodb_atlas_collection_initialization_defaults(mongodb_atlas_unit_test_env, definition):
|
||||
collection = MongoDBAtlasCollection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
)
|
||||
assert collection.database_name == DEFAULT_DB_NAME
|
||||
assert collection.index_name == DEFAULT_SEARCH_INDEX_NAME
|
||||
|
||||
|
||||
async def test_mongodb_atlas_collection_upsert(mongodb_atlas_unit_test_env, definition, mock_get_collection):
|
||||
collection = MongoDBAtlasCollection(
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
collection_name="test_collection",
|
||||
)
|
||||
with patch.object(collection, "_get_collection", new=mock_get_collection) as mock_get:
|
||||
result_mock = AsyncMock(spec=UpdateResult)
|
||||
result_mock.upserted_ids = {0: "test_id"}
|
||||
mock_get.return_value.bulk_write.return_value = result_mock
|
||||
result = await collection._inner_upsert([{"_id": "test_id", "data": "test_data"}])
|
||||
assert result == ["test_id"]
|
||||
|
||||
|
||||
async def test_mongodb_atlas_collection_get(mongodb_atlas_unit_test_env, definition, mock_get_collection):
|
||||
collection = MongoDBAtlasCollection(
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
collection_name="test_collection",
|
||||
)
|
||||
with patch.object(collection, "_get_collection", new=mock_get_collection) as mock_get:
|
||||
result_mock = AsyncMock(spec=AsyncCursor)
|
||||
result_mock.to_list.return_value = [{"_id": "test_id", "data": "test_data"}]
|
||||
mock_get.return_value.find.return_value = result_mock
|
||||
result = await collection._inner_get(["test_id"])
|
||||
assert result == [{"_id": "test_id", "data": "test_data"}]
|
||||
|
||||
|
||||
async def test_mongodb_atlas_collection_delete(mongodb_atlas_unit_test_env, definition, mock_get_collection):
|
||||
collection = MongoDBAtlasCollection(
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
collection_name="test_collection",
|
||||
)
|
||||
with patch.object(collection, "_get_collection", new=mock_get_collection) as mock_get:
|
||||
await collection._inner_delete(["test_id"])
|
||||
mock_get.return_value.delete_many.assert_called_with({"_id": {"$in": ["test_id"]}})
|
||||
|
||||
|
||||
async def test_mongodb_atlas_collection_collection_exists(mongodb_atlas_unit_test_env, definition, mock_get_database):
|
||||
collection = MongoDBAtlasCollection(
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
collection_name="test_collection",
|
||||
)
|
||||
with patch.object(collection, "_get_database", new=mock_get_database) as mock_get:
|
||||
mock_get.return_value.list_collection_names.return_value = ["test_collection"]
|
||||
assert await collection.collection_exists()
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from pymongo import AsyncMongoClient
|
||||
|
||||
from semantic_kernel.connectors.mongodb import MongoDBAtlasCollection, MongoDBAtlasStore
|
||||
|
||||
|
||||
def test_mongodb_atlas_store_initialization(mongodb_atlas_unit_test_env):
|
||||
store = MongoDBAtlasStore()
|
||||
assert store.mongo_client is not None
|
||||
assert isinstance(store.mongo_client, AsyncMongoClient)
|
||||
|
||||
|
||||
def test_mongodb_atlas_store_get_collection(mongodb_atlas_unit_test_env, definition):
|
||||
store = MongoDBAtlasStore()
|
||||
collection = store.get_collection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
)
|
||||
assert collection is not None
|
||||
assert isinstance(collection, MongoDBAtlasCollection)
|
||||
|
||||
|
||||
async def test_mongodb_atlas_store_list_collection_names(mongodb_atlas_unit_test_env, mock_mongo_client):
|
||||
store = MongoDBAtlasStore(mongo_client=mock_mongo_client, database_name="test_db")
|
||||
store.mongo_client.get_database().list_collection_names.return_value = ["test_collection"]
|
||||
result = await store.list_collection_names()
|
||||
assert result == ["test_collection"]
|
||||
@@ -0,0 +1,450 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import numpy as np
|
||||
from azure.search.documents.aio import SearchClient
|
||||
from azure.search.documents.indexes.aio import SearchIndexClient
|
||||
from pytest import fixture, mark, param, raises
|
||||
|
||||
from semantic_kernel.connectors.ai.embedding_generator_base import EmbeddingGeneratorBase
|
||||
from semantic_kernel.connectors.azure_ai_search import (
|
||||
AzureAISearchCollection,
|
||||
AzureAISearchSettings,
|
||||
AzureAISearchStore,
|
||||
_definition_to_azure_ai_search_index,
|
||||
_get_search_index_client,
|
||||
_resolve_credential,
|
||||
)
|
||||
from semantic_kernel.exceptions import (
|
||||
ServiceInitializationError,
|
||||
VectorStoreInitializationException,
|
||||
VectorStoreOperationException,
|
||||
)
|
||||
from semantic_kernel.utils.list_handler import desync_list
|
||||
from tests.unit.connectors.memory.conftest import filter_lambda_list
|
||||
|
||||
BASE_PATH_SEARCH_CLIENT = "azure.search.documents.aio.SearchClient"
|
||||
BASE_PATH_INDEX_CLIENT = "azure.search.documents.indexes.aio.SearchIndexClient"
|
||||
|
||||
|
||||
@fixture
|
||||
def vector_store(azure_ai_search_unit_test_env):
|
||||
"""Fixture to instantiate AzureCognitiveSearchMemoryStore with basic configuration."""
|
||||
return AzureAISearchStore()
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_ensure_collection_exists():
|
||||
"""Fixture to patch 'SearchIndexClient' and its 'create_index' method."""
|
||||
with patch(f"{BASE_PATH_INDEX_CLIENT}.create_index") as mock_create_index:
|
||||
yield mock_create_index
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_ensure_collection_deleted():
|
||||
"""Fixture to patch 'SearchIndexClient' and its 'create_index' method."""
|
||||
with patch(f"{BASE_PATH_INDEX_CLIENT}.delete_index") as mock_delete_index:
|
||||
yield mock_delete_index
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_list_collection_names():
|
||||
"""Fixture to patch 'SearchIndexClient' and its 'create_index' method."""
|
||||
with patch(f"{BASE_PATH_INDEX_CLIENT}.list_index_names") as mock_list_index_names:
|
||||
# Setup the mock to return a specific SearchIndex instance when called
|
||||
mock_list_index_names.return_value = desync_list(["test"])
|
||||
yield mock_list_index_names
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_upsert():
|
||||
with patch(f"{BASE_PATH_SEARCH_CLIENT}.merge_or_upload_documents") as mock_merge_or_upload_documents:
|
||||
from azure.search.documents.models import IndexingResult
|
||||
|
||||
result = MagicMock(spec=IndexingResult)
|
||||
result.key = "id1"
|
||||
mock_merge_or_upload_documents.return_value = [result]
|
||||
yield mock_merge_or_upload_documents
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_get():
|
||||
with patch(f"{BASE_PATH_SEARCH_CLIENT}.get_document") as mock_get_document:
|
||||
mock_get_document.return_value = {"id": "id1", "content": "content", "vector": [1.0, 2.0, 3.0]}
|
||||
yield mock_get_document
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_search():
|
||||
async def iter_search_results(*args, **kwargs):
|
||||
yield {"id": "id1", "content": "content", "vector": [1.0, 2.0, 3.0]}
|
||||
await asyncio.sleep(0.0)
|
||||
|
||||
with patch(f"{BASE_PATH_SEARCH_CLIENT}.search") as mock_search:
|
||||
mock_search.side_effect = iter_search_results
|
||||
yield mock_search
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_delete():
|
||||
with patch(f"{BASE_PATH_SEARCH_CLIENT}.delete_documents") as mock_delete_documents:
|
||||
yield mock_delete_documents
|
||||
|
||||
|
||||
@fixture
|
||||
def collection(azure_ai_search_unit_test_env, definition):
|
||||
return AzureAISearchCollection(record_type=dict, definition=definition)
|
||||
|
||||
|
||||
async def test_init(azure_ai_search_unit_test_env, definition):
|
||||
async with AzureAISearchCollection(record_type=dict, definition=definition) as collection:
|
||||
assert collection is not None
|
||||
assert collection.record_type is dict
|
||||
assert collection.definition == definition
|
||||
assert collection.collection_name == "test-index-name"
|
||||
assert collection.search_index_client is not None
|
||||
assert collection.search_client is not None
|
||||
|
||||
|
||||
def test_init_with_type(azure_ai_search_unit_test_env, record_type):
|
||||
collection = AzureAISearchCollection(record_type=record_type)
|
||||
assert collection is not None
|
||||
assert collection.record_type is record_type
|
||||
assert collection.collection_name == "test-index-name"
|
||||
assert collection.search_index_client is not None
|
||||
assert collection.search_client is not None
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_ENDPOINT"]], indirect=True)
|
||||
def test_init_endpoint_fail(azure_ai_search_unit_test_env, definition):
|
||||
with raises(VectorStoreInitializationException):
|
||||
AzureAISearchCollection(record_type=dict, definition=definition, env_file_path="test.env")
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_INDEX_NAME"]], indirect=True)
|
||||
def test_init_index_fail(azure_ai_search_unit_test_env, definition):
|
||||
with raises(VectorStoreInitializationException):
|
||||
AzureAISearchCollection(record_type=dict, definition=definition, env_file_path="test.env")
|
||||
|
||||
|
||||
def test_init_with_clients(azure_ai_search_unit_test_env, definition):
|
||||
search_index_client = MagicMock(spec=SearchIndexClient)
|
||||
search_client = MagicMock(spec=SearchClient)
|
||||
search_client._index_name = "test-index-name"
|
||||
|
||||
collection = AzureAISearchCollection(
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
search_index_client=search_index_client,
|
||||
search_client=search_client,
|
||||
)
|
||||
assert collection is not None
|
||||
assert collection.record_type is dict
|
||||
assert collection.definition == definition
|
||||
assert collection.collection_name == "test-index-name"
|
||||
assert collection.search_index_client == search_index_client
|
||||
assert collection.search_client == search_client
|
||||
|
||||
|
||||
def test_init_with_search_index_client(azure_ai_search_unit_test_env, definition):
|
||||
search_index_client = MagicMock(spec=SearchIndexClient)
|
||||
with patch("semantic_kernel.connectors.azure_ai_search._get_search_client") as get_search_client:
|
||||
search_client = MagicMock(spec=SearchClient)
|
||||
get_search_client.return_value = search_client
|
||||
|
||||
collection = AzureAISearchCollection(
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
collection_name="test",
|
||||
search_index_client=search_index_client,
|
||||
)
|
||||
assert collection is not None
|
||||
assert collection.record_type is dict
|
||||
assert collection.definition == definition
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.search_index_client == search_index_client
|
||||
assert collection.search_client == search_client
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_INDEX_NAME"]], indirect=True)
|
||||
def test_init_with_search_index_client_fail(azure_ai_search_unit_test_env, definition):
|
||||
search_index_client = MagicMock(spec=SearchIndexClient)
|
||||
with raises(VectorStoreInitializationException):
|
||||
AzureAISearchCollection(
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
search_index_client=search_index_client,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
async def test_upsert(collection, mock_upsert):
|
||||
ids = await collection._inner_upsert({"id": "id1", "name": "test"})
|
||||
assert ids[0] == "id1"
|
||||
|
||||
ids = await collection.upsert(records={"id": "id1", "content": "content", "vector": [1.0, 2.0, 3.0]})
|
||||
assert ids == "id1"
|
||||
|
||||
|
||||
async def test_get(collection, mock_get):
|
||||
records = await collection._inner_get(["id1"])
|
||||
assert records is not None
|
||||
|
||||
records = await collection.get("id1")
|
||||
assert records is not None
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"order_by, ordering",
|
||||
[
|
||||
param("id", ["id"], id="single id"),
|
||||
param({"id": True}, ["id"], id="ascending id"),
|
||||
param({"id": False}, ["id desc"], id="descending id"),
|
||||
param(["id"], ["id"], id="ascending id list"),
|
||||
param(["id", "content"], ["id", "content"], id="multiple"),
|
||||
param([{"id": True}, {"content": False}], ["id", "content desc"], id="multiple desc"),
|
||||
param(["id", {"content": False}], ["id", "content desc"], id="multiple mix"),
|
||||
],
|
||||
)
|
||||
async def test_get_without_key(collection, mock_get, mock_search, order_by, ordering):
|
||||
records = await collection.get(top=10, order_by=order_by)
|
||||
assert records is not None
|
||||
mock_search.assert_called_once_with(
|
||||
search_text="*",
|
||||
top=10,
|
||||
skip=0,
|
||||
select=["id", "content"],
|
||||
order_by=ordering,
|
||||
)
|
||||
|
||||
|
||||
async def test_delete(collection, mock_delete):
|
||||
await collection._inner_delete(["id1"])
|
||||
|
||||
|
||||
async def test_collection_exists(collection, mock_list_collection_names):
|
||||
await collection.collection_exists()
|
||||
|
||||
|
||||
async def test_ensure_collection_deleted(collection, mock_ensure_collection_deleted):
|
||||
await collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
@mark.parametrize("distance_function", [("cosine_distance")])
|
||||
async def test_create_index_from_index(collection, mock_ensure_collection_exists):
|
||||
from azure.search.documents.indexes.models import SearchIndex
|
||||
|
||||
index = MagicMock(spec=SearchIndex)
|
||||
await collection.ensure_collection_exists(index=index)
|
||||
|
||||
|
||||
@mark.parametrize("distance_function", [("cosine_distance")])
|
||||
async def test_create_index_from_definition(collection, mock_ensure_collection_exists):
|
||||
from azure.search.documents.indexes.models import SearchIndex
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.connectors.azure_ai_search._definition_to_azure_ai_search_index",
|
||||
return_value=MagicMock(spec=SearchIndex),
|
||||
):
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
|
||||
async def test_create_index_from_index_fail(collection, mock_ensure_collection_exists):
|
||||
index = Mock()
|
||||
with raises(VectorStoreOperationException):
|
||||
await collection.ensure_collection_exists(index=index)
|
||||
|
||||
|
||||
@mark.parametrize("distance_function", [("cosine_distance")])
|
||||
def test_definition_to_azure_ai_search_index(definition):
|
||||
index = _definition_to_azure_ai_search_index("test", definition)
|
||||
assert index is not None
|
||||
assert index.name == "test"
|
||||
assert len(index.fields) == 3
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_ENDPOINT"]], indirect=True)
|
||||
async def test_vector_store_fail(azure_ai_search_unit_test_env):
|
||||
with raises(VectorStoreInitializationException):
|
||||
AzureAISearchStore(env_file_path="test.env")
|
||||
|
||||
|
||||
async def test_vector_store_list_collection_names(vector_store, mock_list_collection_names):
|
||||
assert vector_store.search_index_client is not None
|
||||
collection_names = await vector_store.list_collection_names()
|
||||
assert collection_names == ["test"]
|
||||
mock_list_collection_names.assert_called_once()
|
||||
|
||||
|
||||
async def test_vector_store_collection_existss(vector_store, mock_list_collection_names):
|
||||
assert vector_store.search_index_client is not None
|
||||
exists = await vector_store.collection_exists("test")
|
||||
assert exists
|
||||
mock_list_collection_names.assert_called_once()
|
||||
|
||||
|
||||
async def test_vector_store_ensure_collection_deleted(vector_store, mock_ensure_collection_deleted):
|
||||
assert vector_store.search_index_client is not None
|
||||
await vector_store.ensure_collection_deleted("test")
|
||||
mock_ensure_collection_deleted.assert_called_once()
|
||||
|
||||
|
||||
def test_get_collection(vector_store, definition):
|
||||
collection = vector_store.get_collection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
)
|
||||
assert collection is not None
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.search_index_client == vector_store.search_index_client
|
||||
assert collection.search_client is not None
|
||||
assert collection.search_endpoint == vector_store.search_endpoint
|
||||
assert collection.search_credential == vector_store.search_credential
|
||||
|
||||
|
||||
def test_get_collection_with_provided_search_index_client(azure_ai_search_unit_test_env, definition):
|
||||
"""Test that get_collection works when AzureAISearchStore is created with a pre-built search_index_client.
|
||||
|
||||
When search_index_client is provided directly, search_endpoint and search_credential
|
||||
are not resolved at store creation time. get_collection() should still succeed
|
||||
by falling back to environment variables for endpoint/credential resolution.
|
||||
"""
|
||||
search_index_client = MagicMock(spec=SearchIndexClient)
|
||||
store = AzureAISearchStore(search_index_client=search_index_client)
|
||||
assert store.search_endpoint is None
|
||||
assert store.search_credential is None
|
||||
|
||||
collection = store.get_collection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
)
|
||||
assert collection is not None
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.search_index_client == search_index_client
|
||||
assert collection.search_client is not None
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_API_KEY"]], indirect=True)
|
||||
def test_get_search_index_client(azure_ai_search_unit_test_env):
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
settings = AzureAISearchSettings(**azure_ai_search_unit_test_env, env_file_path="test.env")
|
||||
|
||||
azure_credential = MagicMock(spec=AzureKeyCredential)
|
||||
client = _get_search_index_client(settings, azure_credential=azure_credential)
|
||||
assert client is not None
|
||||
|
||||
token_credential = MagicMock(spec=AsyncTokenCredential)
|
||||
client2 = _get_search_index_client(
|
||||
settings,
|
||||
token_credential=token_credential,
|
||||
)
|
||||
assert client2 is not None
|
||||
|
||||
with raises(ServiceInitializationError):
|
||||
_get_search_index_client(settings)
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["AZURE_AI_SEARCH_API_KEY"]], indirect=True)
|
||||
def test_resolve_credential(azure_ai_search_unit_test_env):
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
settings = AzureAISearchSettings(**azure_ai_search_unit_test_env, env_file_path="test.env")
|
||||
|
||||
azure_credential = MagicMock(spec=AzureKeyCredential)
|
||||
resolved = _resolve_credential(settings, azure_credential=azure_credential)
|
||||
assert resolved == azure_credential
|
||||
|
||||
token_credential = MagicMock(spec=AsyncTokenCredential)
|
||||
resolved = _resolve_credential(settings, token_credential=token_credential)
|
||||
assert resolved == token_credential
|
||||
|
||||
with raises(ServiceInitializationError):
|
||||
_resolve_credential(settings)
|
||||
|
||||
|
||||
@mark.parametrize("include_vectors", [True, False])
|
||||
async def test_search_vectorized_search(collection, mock_search, include_vectors):
|
||||
results = await collection.search(vector=[0.1, 0.2, 0.3], include_vectors=include_vectors)
|
||||
assert results is not None
|
||||
async for result in results.results:
|
||||
assert result is not None
|
||||
assert result.record is not None
|
||||
assert result.record["id"] == "id1"
|
||||
assert result.record["content"] == "content"
|
||||
if include_vectors:
|
||||
assert result.record["vector"] == [1.0, 2.0, 3.0]
|
||||
for call in mock_search.call_args_list:
|
||||
assert call[1]["top"] == 3
|
||||
assert call[1]["skip"] == 0
|
||||
assert call[1]["include_total_count"] is False
|
||||
assert call[1]["select"] == ["*"] if include_vectors else ["id", "content"]
|
||||
assert call[1]["vector_queries"][0].vector == [0.1, 0.2, 0.3]
|
||||
assert call[1]["vector_queries"][0].fields == "vector"
|
||||
|
||||
|
||||
@mark.parametrize("include_vectors", [True, False])
|
||||
async def test_search_vectorizable_search(collection, mock_search, include_vectors):
|
||||
collection.embedding_generator = AsyncMock(spec=EmbeddingGeneratorBase)
|
||||
collection.embedding_generator.generate_embeddings.return_value = np.array([[0.1, 0.2, 0.3]])
|
||||
results = await collection.search("test", include_vectors=include_vectors)
|
||||
assert results is not None
|
||||
async for result in results.results:
|
||||
assert result is not None
|
||||
assert result.record is not None
|
||||
assert result.record["id"] == "id1"
|
||||
assert result.record["content"] == "content"
|
||||
if include_vectors:
|
||||
assert result.record["vector"] == [1.0, 2.0, 3.0]
|
||||
for call in mock_search.call_args_list:
|
||||
assert call[1]["top"] == 3
|
||||
assert call[1]["skip"] == 0
|
||||
assert call[1]["include_total_count"] is False
|
||||
assert call[1]["select"] == ["*"] if include_vectors else ["id", "content"]
|
||||
assert call[1]["vector_queries"][0].vector == [0.1, 0.2, 0.3]
|
||||
assert call[1]["vector_queries"][0].fields == "vector"
|
||||
|
||||
|
||||
@mark.parametrize("include_vectors", [True, False])
|
||||
@mark.parametrize("keywords", ["test", ["test1", "test2"]], ids=["single", "multiple"])
|
||||
async def test_search_keyword_hybrid_search(collection, mock_search, include_vectors, keywords):
|
||||
results = await collection.hybrid_search(
|
||||
values=keywords,
|
||||
vector=[0.1, 0.2, 0.3],
|
||||
include_vectors=include_vectors,
|
||||
additional_property_name="content",
|
||||
)
|
||||
assert results is not None
|
||||
async for result in results.results:
|
||||
assert result is not None
|
||||
assert result.record is not None
|
||||
assert result.record["id"] == "id1"
|
||||
assert result.record["content"] == "content"
|
||||
if include_vectors:
|
||||
assert result.record["vector"] == [1.0, 2.0, 3.0]
|
||||
for call in mock_search.call_args_list:
|
||||
assert call[1]["top"] == 3
|
||||
assert call[1]["skip"] == 0
|
||||
assert call[1]["include_total_count"] is False
|
||||
assert call[1]["select"] == ["*"] if include_vectors else ["id", "content"]
|
||||
assert call[1]["search_fields"] == ["content"]
|
||||
assert call[1]["search_text"] == "test" if keywords == "test" else "test1, test2"
|
||||
assert call[1]["vector_queries"][0].vector == [0.1, 0.2, 0.3]
|
||||
assert call[1]["vector_queries"][0].fields == "vector"
|
||||
|
||||
|
||||
@mark.parametrize("filter, result", filter_lambda_list("ai_search"))
|
||||
def test_lambda_filter(collection, filter, result):
|
||||
if isinstance(result, type) and issubclass(result, Exception):
|
||||
with raises(result):
|
||||
collection._build_filter(filter)
|
||||
else:
|
||||
filter_string = collection._build_filter(filter)
|
||||
assert filter_string == result
|
||||
@@ -0,0 +1,115 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.api.models.Collection import Collection
|
||||
|
||||
from semantic_kernel.connectors.chroma import ChromaCollection, ChromaStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
return MagicMock(spec=ClientAPI)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chroma_collection(mock_client, definition):
|
||||
return ChromaCollection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chroma_store(mock_client):
|
||||
return ChromaStore(client=mock_client)
|
||||
|
||||
|
||||
def test_chroma_collection_initialization(chroma_collection):
|
||||
assert chroma_collection.collection_name == "test_collection"
|
||||
assert chroma_collection.record_type is dict
|
||||
|
||||
|
||||
def test_chroma_store_initialization(chroma_store):
|
||||
assert chroma_store.client is not None
|
||||
|
||||
|
||||
def test_chroma_collection_get_collection(chroma_collection, mock_client):
|
||||
mock_client.get_collection.return_value = "mock_collection"
|
||||
collection = chroma_collection._get_collection()
|
||||
assert collection == "mock_collection"
|
||||
|
||||
|
||||
def test_chroma_store_get_collection(chroma_store, mock_client, definition):
|
||||
collection = chroma_store.get_collection(collection_name="test_collection", record_type=dict, definition=definition)
|
||||
assert collection is not None
|
||||
assert isinstance(collection, ChromaCollection)
|
||||
|
||||
|
||||
async def test_chroma_collection_collection_exists(chroma_collection, mock_client):
|
||||
mock_client.get_collection.return_value = "mock_collection"
|
||||
exists = await chroma_collection.collection_exists()
|
||||
assert exists
|
||||
|
||||
|
||||
async def test_chroma_store_list_collection_names(chroma_store, mock_client):
|
||||
mock_collection = MagicMock(spec=Collection)
|
||||
mock_collection.name = "test_collection"
|
||||
mock_client.list_collections.return_value = [mock_collection]
|
||||
collections = await chroma_store.list_collection_names()
|
||||
assert collections == ["test_collection"]
|
||||
|
||||
|
||||
async def test_chroma_collection_ensure_collection_exists(chroma_collection, mock_client):
|
||||
await chroma_collection.ensure_collection_exists()
|
||||
mock_client.create_collection.assert_called_once_with(
|
||||
name="test_collection", embedding_function=None, configuration={"hnsw": {"space": "cosine"}}, get_or_create=True
|
||||
)
|
||||
|
||||
|
||||
async def test_chroma_collection_ensure_collection_deleted(chroma_collection, mock_client):
|
||||
await chroma_collection.ensure_collection_deleted()
|
||||
mock_client.delete_collection.assert_called_once_with(name="test_collection")
|
||||
|
||||
|
||||
async def test_chroma_collection_upsert(chroma_collection, mock_client):
|
||||
records = [{"id": "1", "vector": [0.1, 0.2, 0.3, 0.4, 0.5], "content": "test document"}]
|
||||
ids = await chroma_collection.upsert(records)
|
||||
assert ids == ["1"]
|
||||
mock_client.get_collection().add.assert_called_once()
|
||||
|
||||
|
||||
async def test_chroma_collection_get(chroma_collection, mock_client):
|
||||
mock_client.get_collection().get.return_value = {
|
||||
"ids": [["1"]],
|
||||
"documents": [["test document"]],
|
||||
"embeddings": [[[0.1, 0.2, 0.3, 0.4, 0.5]]],
|
||||
"metadatas": [[{}]],
|
||||
}
|
||||
records = await chroma_collection._inner_get(["1"])
|
||||
assert len(records) == 1
|
||||
assert records[0]["id"] == "1"
|
||||
|
||||
|
||||
async def test_chroma_collection_delete(chroma_collection, mock_client):
|
||||
await chroma_collection._inner_delete(["1"])
|
||||
mock_client.get_collection().delete.assert_called_once_with(ids=["1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_vectors", [True, False])
|
||||
async def test_chroma_collection_search(chroma_collection, mock_client, include_vectors):
|
||||
mock_client.get_collection().query.return_value = {
|
||||
"ids": [["1"]],
|
||||
"documents": [["test document"]],
|
||||
"embeddings": [[[0.1, 0.2, 0.3, 0.4, 0.5]]],
|
||||
"metadatas": [[{}]],
|
||||
"distances": [[0.1]],
|
||||
}
|
||||
results = await chroma_collection.search(vector=[0.1, 0.2, 0.3, 0.4, 0.5], top=1, include_vectors=include_vectors)
|
||||
async for res in results.results:
|
||||
assert res.record["id"] == "1"
|
||||
assert res.score == 0.1
|
||||
@@ -0,0 +1,176 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import faiss
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
from semantic_kernel.connectors.faiss import FaissCollection, FaissStore
|
||||
from semantic_kernel.data.vector import DistanceFunction, VectorStoreCollectionDefinition, VectorStoreField
|
||||
from semantic_kernel.exceptions import VectorStoreInitializationException
|
||||
|
||||
|
||||
@fixture(scope="function")
|
||||
def data_model_def() -> VectorStoreCollectionDefinition:
|
||||
return VectorStoreCollectionDefinition(
|
||||
fields=[
|
||||
VectorStoreField("key", name="id"),
|
||||
VectorStoreField("data", name="content"),
|
||||
VectorStoreField(
|
||||
"vector",
|
||||
name="vector",
|
||||
dimensions=5,
|
||||
index_kind="flat",
|
||||
distance_function="dot_prod",
|
||||
type="float",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@fixture(scope="function")
|
||||
def store() -> FaissStore:
|
||||
return FaissStore()
|
||||
|
||||
|
||||
@fixture(scope="function")
|
||||
def faiss_collection(data_model_def):
|
||||
return FaissCollection(record_type=dict, definition=data_model_def, collection_name="test")
|
||||
|
||||
|
||||
async def test_store_get_collection(store, data_model_def):
|
||||
collection = store.get_collection(dict, definition=data_model_def, collection_name="test")
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.record_type is dict
|
||||
assert collection.definition == data_model_def
|
||||
assert collection.inner_storage == {}
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"dist",
|
||||
[
|
||||
DistanceFunction.EUCLIDEAN_SQUARED_DISTANCE,
|
||||
DistanceFunction.DOT_PROD,
|
||||
],
|
||||
)
|
||||
async def test_ensure_collection_exists(store, data_model_def, dist):
|
||||
for field in data_model_def.fields:
|
||||
if field.name == "vector":
|
||||
field.distance_function = dist
|
||||
collection = store.get_collection(collection_name="test", record_type=dict, definition=data_model_def)
|
||||
await collection.ensure_collection_exists()
|
||||
assert collection.inner_storage == {}
|
||||
assert collection.indexes
|
||||
assert collection.indexes["vector"] is not None
|
||||
|
||||
|
||||
async def test_ensure_collection_exists_incompatible_dist(store, data_model_def):
|
||||
for field in data_model_def.fields:
|
||||
if field.name == "vector":
|
||||
field.distance_function = "cosine_distance"
|
||||
collection = store.get_collection(collection_name="test", record_type=dict, definition=data_model_def)
|
||||
with raises(VectorStoreInitializationException):
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
|
||||
async def test_ensure_collection_exists_custom(store, data_model_def):
|
||||
index = faiss.IndexFlat(5)
|
||||
collection = store.get_collection(collection_name="test", record_type=dict, definition=data_model_def)
|
||||
await collection.ensure_collection_exists(index=index)
|
||||
assert collection.inner_storage == {}
|
||||
assert collection.indexes
|
||||
assert collection.indexes["vector"] is not None
|
||||
assert collection.indexes["vector"] == index
|
||||
assert collection.indexes["vector"].is_trained is True
|
||||
await collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
async def test_ensure_collection_exists_custom_untrained(store, data_model_def):
|
||||
index = faiss.IndexIVFFlat(faiss.IndexFlat(5), 5, 10)
|
||||
collection = store.get_collection(collection_name="test", record_type=dict, definition=data_model_def)
|
||||
with raises(VectorStoreInitializationException):
|
||||
await collection.ensure_collection_exists(index=index)
|
||||
del index
|
||||
|
||||
|
||||
async def test_ensure_collection_exists_custom_dict(store, data_model_def):
|
||||
index = faiss.IndexFlat(5)
|
||||
collection = store.get_collection(collection_name="test", record_type=dict, definition=data_model_def)
|
||||
await collection.ensure_collection_exists(indexes={"vector": index})
|
||||
assert collection.inner_storage == {}
|
||||
assert collection.indexes
|
||||
assert collection.indexes["vector"] is not None
|
||||
assert collection.indexes["vector"] == index
|
||||
await collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
async def test_upsert(faiss_collection):
|
||||
await faiss_collection.ensure_collection_exists()
|
||||
record = {"id": "testid", "content": "test content", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
key = await faiss_collection.upsert(record)
|
||||
assert key == "testid"
|
||||
assert faiss_collection.inner_storage == {"testid": record}
|
||||
await faiss_collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
async def test_get(faiss_collection):
|
||||
await faiss_collection.ensure_collection_exists()
|
||||
record = {"id": "testid", "content": "test content", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
await faiss_collection.upsert(record)
|
||||
result = await faiss_collection.get("testid")
|
||||
assert result["id"] == record["id"]
|
||||
assert result["content"] == record["content"]
|
||||
await faiss_collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
async def test_get_missing(faiss_collection):
|
||||
await faiss_collection.ensure_collection_exists()
|
||||
result = await faiss_collection.get("testid")
|
||||
assert result is None
|
||||
await faiss_collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
async def test_delete(faiss_collection):
|
||||
await faiss_collection.ensure_collection_exists()
|
||||
record = {"id": "testid", "content": "test content", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
await faiss_collection.upsert(record)
|
||||
await faiss_collection.delete("testid")
|
||||
assert faiss_collection.inner_storage == {}
|
||||
await faiss_collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
async def test_collection_exists(faiss_collection):
|
||||
assert await faiss_collection.collection_exists() is False
|
||||
await faiss_collection.ensure_collection_exists()
|
||||
assert await faiss_collection.collection_exists() is True
|
||||
await faiss_collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
async def test_ensure_collection_deleted(faiss_collection):
|
||||
await faiss_collection.ensure_collection_exists()
|
||||
record = {"id": "testid", "content": "test content", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
await faiss_collection.upsert(record)
|
||||
assert faiss_collection.inner_storage == {"testid": record}
|
||||
await faiss_collection.ensure_collection_deleted()
|
||||
assert faiss_collection.inner_storage == {}
|
||||
|
||||
|
||||
@mark.parametrize("dist", [DistanceFunction.EUCLIDEAN_SQUARED_DISTANCE, DistanceFunction.DOT_PROD])
|
||||
async def test_ensure_collection_exists_and_search(faiss_collection, dist):
|
||||
for field in faiss_collection.definition.fields:
|
||||
if field.name == "vector":
|
||||
field.distance_function = dist
|
||||
await faiss_collection.ensure_collection_exists()
|
||||
record1 = {"id": "testid1", "content": "test content", "vector": [1.0, 1.0, 1.0, 1.0, 1.0]}
|
||||
record2 = {"id": "testid2", "content": "test content", "vector": [-1.0, -1.0, -1.0, -1.0, -1.0]}
|
||||
await faiss_collection.upsert([record1, record2])
|
||||
results = await faiss_collection.search(
|
||||
vector=[0.9, 0.9, 0.9, 0.9, 0.9],
|
||||
vector_property_name="vector",
|
||||
include_total_count=True,
|
||||
include_vectors=True,
|
||||
)
|
||||
assert results.total_count == 2
|
||||
idx = 0
|
||||
async for res in results.results:
|
||||
assert res.record == record1 if idx == 0 else record2
|
||||
idx += 1
|
||||
await faiss_collection.ensure_collection_deleted()
|
||||
@@ -0,0 +1,294 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import ast
|
||||
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
from semantic_kernel.connectors.in_memory import InMemoryCollection, InMemoryStore
|
||||
from semantic_kernel.data._shared import default_dynamic_filter_function
|
||||
from semantic_kernel.data.vector import DistanceFunction
|
||||
from semantic_kernel.exceptions.vector_store_exceptions import VectorStoreOperationException
|
||||
|
||||
|
||||
@fixture
|
||||
def collection(definition):
|
||||
return InMemoryCollection(collection_name="test", record_type=dict, definition=definition)
|
||||
|
||||
|
||||
def test_store_init():
|
||||
store = InMemoryStore()
|
||||
assert store is not None
|
||||
|
||||
|
||||
def test_store_get_collection(definition):
|
||||
store = InMemoryStore()
|
||||
collection = store.get_collection(collection_name="test", record_type=dict, definition=definition)
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.record_type is dict
|
||||
assert collection.definition == definition
|
||||
|
||||
|
||||
async def test_upsert(collection):
|
||||
record = {"id": "testid", "content": "test content", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
key = await collection.upsert(record)
|
||||
assert key == "testid"
|
||||
assert collection.inner_storage == {"testid": record}
|
||||
|
||||
|
||||
async def test_get(collection):
|
||||
record = {"id": "testid", "content": "test content", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
await collection.upsert(record)
|
||||
result = await collection.get("testid")
|
||||
assert result["id"] == record["id"]
|
||||
assert result["content"] == record["content"]
|
||||
|
||||
|
||||
async def test_get_missing(collection):
|
||||
result = await collection.get("testid")
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_delete(collection):
|
||||
record = {"id": "testid", "content": "test content", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
await collection.upsert(record)
|
||||
await collection.delete("testid")
|
||||
assert collection.inner_storage == {}
|
||||
|
||||
|
||||
async def test_collection_exists(collection):
|
||||
assert await collection.collection_exists() is True
|
||||
|
||||
|
||||
async def test_ensure_collection_deleted(collection):
|
||||
record = {"id": "testid", "content": "test content", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
await collection.upsert(record)
|
||||
assert collection.inner_storage == {"testid": record}
|
||||
await collection.ensure_collection_deleted()
|
||||
assert collection.inner_storage == {}
|
||||
|
||||
|
||||
async def test_ensure_collection_exists(collection):
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"distance_function",
|
||||
[
|
||||
DistanceFunction.COSINE_DISTANCE,
|
||||
DistanceFunction.COSINE_SIMILARITY,
|
||||
DistanceFunction.EUCLIDEAN_DISTANCE,
|
||||
DistanceFunction.MANHATTAN,
|
||||
DistanceFunction.EUCLIDEAN_SQUARED_DISTANCE,
|
||||
DistanceFunction.DOT_PROD,
|
||||
DistanceFunction.HAMMING,
|
||||
],
|
||||
)
|
||||
async def test_vectorized_search_similar(collection, distance_function):
|
||||
for field in collection.definition.fields:
|
||||
if field.name == "vector":
|
||||
field.distance_function = distance_function
|
||||
record1 = {"id": "testid1", "content": "test content", "vector": [1.0, 1.0, 1.0, 1.0, 1.0]}
|
||||
record2 = {"id": "testid2", "content": "test content", "vector": [-1.0, -1.0, -1.0, -1.0, -1.0]}
|
||||
await collection.upsert([record1, record2])
|
||||
results = await collection.search(
|
||||
vector=[0.9, 0.9, 0.9, 0.9, 0.9],
|
||||
vector_property_name="vector",
|
||||
include_total_count=True,
|
||||
include_vectors=True,
|
||||
)
|
||||
assert results.total_count == 2
|
||||
idx = 0
|
||||
async for res in results.results:
|
||||
assert res.record == record1 if idx == 0 else record2
|
||||
idx += 1
|
||||
|
||||
|
||||
async def test_valid_lambda_filter(collection):
|
||||
record1 = {"id": "1", "vector": [1, 2, 3, 4, 5]}
|
||||
record2 = {"id": "2", "vector": [5, 4, 3, 2, 1]}
|
||||
await collection.upsert([record1, record2])
|
||||
# Filter to select only record with id == '1'
|
||||
results = collection._get_filtered_records(type("opt", (), {"filter": "lambda x: x.id == '1'"})())
|
||||
assert len(results) == 1
|
||||
assert "1" in results
|
||||
|
||||
|
||||
async def test_valid_lambda_filter_attribute_access(collection):
|
||||
record1 = {"id": "1", "vector": [1, 2, 3, 4, 5]}
|
||||
record2 = {"id": "2", "vector": [5, 4, 3, 2, 1]}
|
||||
await collection.upsert([record1, record2])
|
||||
# Filter to select only record with id == '2' using attribute access
|
||||
results = collection._get_filtered_records(type("opt", (), {"filter": "lambda x: x['id'] == '2'"})())
|
||||
assert len(results) == 1
|
||||
assert "2" in results
|
||||
|
||||
|
||||
async def test_invalid_filter_not_lambda(collection):
|
||||
with raises(VectorStoreOperationException, match="must be a lambda expression"):
|
||||
collection._get_filtered_records(type("opt", (), {"filter": "x.id == '1'"})())
|
||||
|
||||
|
||||
async def test_invalid_filter_syntax(collection):
|
||||
with raises(VectorStoreOperationException, match="not valid Python"):
|
||||
collection._get_filtered_records(type("opt", (), {"filter": "lambda x: x.id == '1' and"})())
|
||||
|
||||
|
||||
async def test_malicious_filter_import(collection):
|
||||
# Should not allow import statement
|
||||
with raises(VectorStoreOperationException):
|
||||
collection._get_filtered_records(
|
||||
type("opt", (), {"filter": "lambda x: __import__('os').system('echo malicious')"})()
|
||||
)
|
||||
|
||||
|
||||
async def test_malicious_filter_exec(collection):
|
||||
# Should not allow exec or similar
|
||||
with raises(VectorStoreOperationException):
|
||||
collection._get_filtered_records(type("opt", (), {"filter": "lambda x: exec('print(1)')"})())
|
||||
|
||||
|
||||
async def test_malicious_filter_builtins(collection):
|
||||
# Should not allow access to builtins
|
||||
with raises(VectorStoreOperationException):
|
||||
collection._get_filtered_records(
|
||||
type("opt", (), {"filter": "lambda x: __builtins__.__import__('os').system('echo malicious')"})()
|
||||
)
|
||||
|
||||
|
||||
async def test_malicious_filter_open(collection):
|
||||
# Should not allow open()
|
||||
with raises(VectorStoreOperationException):
|
||||
collection._get_filtered_records(type("opt", (), {"filter": "lambda x: open('somefile.txt', 'w')"})())
|
||||
|
||||
|
||||
async def test_malicious_filter_eval(collection):
|
||||
# Should not allow eval()
|
||||
with raises(VectorStoreOperationException):
|
||||
collection._get_filtered_records(type("opt", (), {"filter": "lambda x: eval('2+2')"})())
|
||||
|
||||
|
||||
async def test_multiple_filters(collection):
|
||||
record1 = {"id": "1", "vector": [1, 2, 3, 4, 5]}
|
||||
record2 = {"id": "2", "vector": [5, 4, 3, 2, 1]}
|
||||
await collection.upsert([record1, record2])
|
||||
filters = ["lambda x: x.id == '1'", "lambda x: x.vector[0] == 1"]
|
||||
results = collection._get_filtered_records(type("opt", (), {"filter": filters})())
|
||||
assert len(results) == 1
|
||||
assert "1" in results
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"filter_str",
|
||||
[
|
||||
"lambda x: [x.clear][0]() or True",
|
||||
"lambda x: [x.update][0]({'role': 'admin'}) or True",
|
||||
"lambda x: [x.pop][0]('secret', '') or True",
|
||||
"lambda x: [x.__setitem__][0]('leaked', ['{0.__class__.__mro__}'.format][0](x)) or True",
|
||||
],
|
||||
)
|
||||
def test_malicious_subscript_call_patterns_blocked(collection, filter_str):
|
||||
with raises(VectorStoreOperationException, match="Call target node type 'Subscript' is not allowed"):
|
||||
collection._parse_and_validate_filter(filter_str)
|
||||
|
||||
|
||||
def test_direct_mutating_method_call_remains_blocked(collection):
|
||||
with raises(VectorStoreOperationException, match="Function 'clear' is not allowed"):
|
||||
collection._parse_and_validate_filter("lambda x: x.clear() or True")
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"attr",
|
||||
[
|
||||
"__base__",
|
||||
"__bases__",
|
||||
"__class__",
|
||||
"__mro__",
|
||||
"__subclasses__",
|
||||
"__globals__",
|
||||
],
|
||||
)
|
||||
def test_blocked_dunder_attributes_rejected(collection, attr):
|
||||
with raises(VectorStoreOperationException, match=f"Access to attribute '{attr}' is not allowed"):
|
||||
collection._parse_and_validate_filter(f"lambda x: x.{attr}")
|
||||
|
||||
|
||||
async def test_valid_lambda_filter_with_get_method(collection):
|
||||
record1 = {"id": "1", "vector": [1, 2, 3, 4, 5]}
|
||||
record2 = {"id": "2", "vector": [5, 4, 3, 2, 1]}
|
||||
await collection.upsert([record1, record2])
|
||||
results = collection._get_filtered_records(type("opt", (), {"filter": "lambda x: x.get('id') == '1'"})())
|
||||
assert len(results) == 1
|
||||
assert "1" in results
|
||||
|
||||
|
||||
async def test_valid_lambda_filter_with_bounded_sequence_repeat(collection):
|
||||
record = {"id": "1", "vector": [1, 2, 3, 4, 5]}
|
||||
await collection.upsert(record)
|
||||
|
||||
results = collection._get_filtered_records(type("opt", (), {"filter": "lambda x: ([0] * 2)[1] == 0"})())
|
||||
|
||||
assert len(results) == 1
|
||||
assert "1" in results
|
||||
|
||||
|
||||
async def test_sequence_repeat_limit_can_be_overridden(collection):
|
||||
record = {"id": "1", "vector": [1, 2, 3, 4, 5]}
|
||||
await collection.upsert(record)
|
||||
filter_options = type("opt", (), {"filter": "lambda x: ([0] * 2)[1] == 0"})()
|
||||
|
||||
collection.max_filter_sequence_repeat_size = 1
|
||||
with raises(VectorStoreOperationException, match="Sequence repetition in filter expressions exceeds the maximum"):
|
||||
collection._get_filtered_records(filter_options)
|
||||
|
||||
collection.max_filter_sequence_repeat_size = 2
|
||||
results = collection._get_filtered_records(filter_options)
|
||||
|
||||
assert len(results) == 1
|
||||
assert "1" in results
|
||||
|
||||
|
||||
async def test_callable_filter_cannot_mutate_stored_record(collection):
|
||||
record = {"id": "1", "content": "value", "vector": [1, 2, 3, 4, 5]}
|
||||
await collection.upsert(record)
|
||||
|
||||
def mutating_filter(x):
|
||||
x["role"] = "admin"
|
||||
return True
|
||||
|
||||
with raises(VectorStoreOperationException, match="Error running filter"):
|
||||
collection._get_filtered_records(type("opt", (), {"filter": mutating_filter})())
|
||||
|
||||
assert "role" not in collection.inner_storage["1"]
|
||||
assert collection.inner_storage["1"]["content"] == "value"
|
||||
|
||||
|
||||
def test_default_dynamic_filter_injection_payload_remains_string_literal(collection):
|
||||
class Param:
|
||||
def __init__(self, name, default_value=None):
|
||||
self.name = name
|
||||
self.default_value = default_value
|
||||
|
||||
injected_value = "' or [x.update][0]({'role':'admin'}) or x.name=='"
|
||||
generated_filter = default_dynamic_filter_function(
|
||||
filter=None,
|
||||
parameters=[Param("category")],
|
||||
category=injected_value,
|
||||
)
|
||||
|
||||
assert isinstance(generated_filter, str)
|
||||
tree = ast.parse(generated_filter, mode="eval")
|
||||
assert isinstance(tree.body, ast.Lambda)
|
||||
assert isinstance(tree.body.body, ast.Compare)
|
||||
assert isinstance(tree.body.body.comparators[0], ast.Constant)
|
||||
assert tree.body.body.comparators[0].value == injected_value
|
||||
|
||||
filter_func = collection._parse_and_validate_filter(generated_filter)
|
||||
assert filter_func({"category": "finance", "name": "alice", "vector": [0.1] * 5}) is False
|
||||
|
||||
|
||||
async def test_large_sequence_repeat_filter_is_blocked(collection):
|
||||
record = {"id": "1", "content": "value", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
await collection.upsert(record)
|
||||
|
||||
with raises(VectorStoreOperationException, match="Sequence repetition in filter expressions exceeds the maximum"):
|
||||
collection._get_filtered_records(type("opt", (), {"filter": "lambda x: [0] * 2000000000"})())
|
||||
@@ -0,0 +1,421 @@
|
||||
# Copyright (c) 2025, Oracle Corporation. All rights reserved. # noqa: CPY001
|
||||
|
||||
from array import array
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Annotated
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import oracledb
|
||||
import pandas as pd
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from semantic_kernel.connectors.oracle import OracleCollection, OracleStore
|
||||
from semantic_kernel.data.vector import (
|
||||
DistanceFunction,
|
||||
IndexKind,
|
||||
VectorStoreCollectionDefinition,
|
||||
VectorStoreField,
|
||||
vectorstoremodel,
|
||||
)
|
||||
|
||||
|
||||
@vectorstoremodel
|
||||
@dataclass
|
||||
class SimpleModel:
|
||||
id: Annotated[int, VectorStoreField("key")]
|
||||
vector: Annotated[
|
||||
list[float] | None,
|
||||
VectorStoreField(
|
||||
"vector",
|
||||
type="float",
|
||||
dimensions=3,
|
||||
index_kind=IndexKind.HNSW,
|
||||
distance_function=DistanceFunction.EUCLIDEAN_SQUARED_DISTANCE,
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
def PandasDataframeModel(record) -> tuple:
|
||||
definition = VectorStoreCollectionDefinition(
|
||||
fields=[
|
||||
VectorStoreField("key", name="id", type="int"),
|
||||
VectorStoreField(
|
||||
"vector",
|
||||
name="embedding",
|
||||
type="float32",
|
||||
dimensions=5,
|
||||
distance_function=DistanceFunction.COSINE_DISTANCE,
|
||||
index_kind=IndexKind.IVF_FLAT,
|
||||
),
|
||||
],
|
||||
to_dict=lambda record, **_: record.to_dict(orient="records"),
|
||||
from_dict=lambda records, **_: pd.DataFrame(records),
|
||||
container_mode=True,
|
||||
)
|
||||
df = pd.DataFrame([record]) if isinstance(record, dict) else pd.DataFrame(record)
|
||||
return definition, df
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_connection_pool():
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchall = AsyncMock(return_value=[("COLL1",), ("COLL2",)])
|
||||
|
||||
mock_context_manager = AsyncMock()
|
||||
mock_context_manager.__aenter__.return_value = mock_conn
|
||||
mock_context_manager.__aexit__.return_value = None
|
||||
|
||||
pool = MagicMock(spec=oracledb.AsyncConnectionPool)
|
||||
pool.acquire.return_value = mock_context_manager
|
||||
|
||||
return pool
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def oracle_store(mock_connection_pool):
|
||||
return OracleStore(
|
||||
connection_pool=mock_connection_pool,
|
||||
db_schema="MY_SCHEMA",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_collection_names_with_schema(oracle_store):
|
||||
names = await oracle_store.list_collection_names()
|
||||
assert names == ["COLL1", "COLL2"]
|
||||
|
||||
|
||||
def test_get_collection_returns_oracle_collection(oracle_store):
|
||||
collection = oracle_store.get_collection(
|
||||
SimpleModel,
|
||||
collection_name="TEST",
|
||||
)
|
||||
assert isinstance(collection, OracleCollection)
|
||||
assert collection.collection_name == "TEST"
|
||||
assert collection.db_schema == "MY_SCHEMA"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_exists_true(oracle_store, mock_connection_pool):
|
||||
conn = AsyncMock()
|
||||
conn.fetchone = AsyncMock(return_value=(1,))
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = conn
|
||||
|
||||
collection = oracle_store.get_collection(
|
||||
SimpleModel,
|
||||
collection_name="EXISTING",
|
||||
)
|
||||
result = await collection.collection_exists()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_exists_false(oracle_store, mock_connection_pool):
|
||||
conn = AsyncMock()
|
||||
conn.fetchone = AsyncMock(return_value=None)
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = conn
|
||||
|
||||
collection = oracle_store.get_collection(
|
||||
SimpleModel,
|
||||
collection_name="MISSING",
|
||||
)
|
||||
result = await collection.collection_exists()
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_collection_exists_creates_when_missing(oracle_store, mock_connection_pool):
|
||||
conn = AsyncMock()
|
||||
conn.fetchone = AsyncMock(return_value=False)
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = conn
|
||||
collection = oracle_store.get_collection(SimpleModel, collection_name="NEW")
|
||||
await collection.ensure_collection_exists()
|
||||
conn.execute.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_table_with_get_collection(oracle_store, mock_connection_pool):
|
||||
mock_conn = AsyncMock()
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
||||
|
||||
collection = oracle_store.get_collection(SimpleModel, collection_name="MY_COLLECTION")
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
mock_conn.execute.assert_awaited()
|
||||
sql_statements = [args[0].lower() for name, args, _ in mock_conn.mock_calls if name == "execute"]
|
||||
|
||||
assert any("create table" in sql for sql in sql_statements)
|
||||
assert any("my_collection" in sql for sql in sql_statements)
|
||||
assert any("vector(3 , float64)" in sql for sql in sql_statements)
|
||||
|
||||
mock_conn.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pandasDataframe_with_get_collection(oracle_store, mock_connection_pool):
|
||||
mock_conn = AsyncMock()
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
||||
|
||||
records = {
|
||||
"id": 1,
|
||||
"embedding": [1.1, 2.2, 3.3],
|
||||
}
|
||||
|
||||
definition, _ = PandasDataframeModel(records)
|
||||
|
||||
collection = oracle_store.get_collection(
|
||||
collection_name="MY_COLLECTION",
|
||||
record_type=pd.DataFrame,
|
||||
definition=definition,
|
||||
)
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
mock_conn.execute.assert_awaited()
|
||||
sql_statements = [args[0].lower() for name, args, _ in mock_conn.mock_calls if name == "execute"]
|
||||
|
||||
assert any("create table" in sql for sql in sql_statements)
|
||||
assert any("my_collection" in sql for sql in sql_statements)
|
||||
assert any("vector(5 , float32)" in sql for sql in sql_statements)
|
||||
|
||||
mock_conn.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_creation_distance_with_get_collection(oracle_store, mock_connection_pool):
|
||||
mock_conn = AsyncMock()
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
||||
|
||||
collection = oracle_store.get_collection(SimpleModel, collection_name="COLLECTION_WITH_INDEX")
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
mock_conn.execute.assert_awaited()
|
||||
called_sql = mock_conn.execute.call_args[0][0].lower()
|
||||
|
||||
assert "create vector index" in called_sql
|
||||
assert "collection_with_index_vector_idx" in called_sql
|
||||
assert "inmemory neighbor graph" in called_sql
|
||||
assert "distance euclidean_squared" in called_sql
|
||||
|
||||
mock_conn.commit.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_collection_deleted(oracle_store, mock_connection_pool):
|
||||
conn = AsyncMock()
|
||||
conn.fetchone = AsyncMock(return_value=(1,))
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = conn
|
||||
|
||||
collection = oracle_store.get_collection(SimpleModel, collection_name="TO_DELETE")
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
await collection.ensure_collection_deleted()
|
||||
|
||||
assert any("DROP TABLE" in str(call.args[0]) for call in conn.execute.call_args_list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert(oracle_store, mock_connection_pool):
|
||||
mock_conn = AsyncMock()
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
||||
|
||||
collection = oracle_store.get_collection(SimpleModel, collection_name="MY_COLLECTION")
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
await collection.upsert(SimpleModel(id=1, vector=[0.1, 0.2, 0.3]))
|
||||
|
||||
mock_conn.executemany.assert_called_once()
|
||||
|
||||
merge_sql, params = mock_conn.executemany.call_args[0]
|
||||
|
||||
assert merge_sql.startswith('MERGE INTO "MY_SCHEMA"."MY_COLLECTION"')
|
||||
assert 'UPDATE SET t."vector"' in merge_sql
|
||||
assert "WHEN NOT MATCHED THEN" in merge_sql
|
||||
assert 'INSERT ("id", "vector")' in merge_sql
|
||||
|
||||
expected_param = (1, array("d", [0.1, 0.2, 0.3]))
|
||||
assert params[0] == expected_param
|
||||
assert mock_conn.commit.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_include_vectors(oracle_store, mock_connection_pool):
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.description = [("id",), ("vector",)]
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = mock_conn
|
||||
|
||||
collection = oracle_store.get_collection(SimpleModel, collection_name="MY_COLLECTION")
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
await collection.upsert(SimpleModel(id=1, vector=[0.1, 0.2, 0.3]))
|
||||
|
||||
mock_conn.fetchall.return_value = [(1, [0.1, 0.2, 0.3])]
|
||||
results = await collection.get([1], include_vectors=True)
|
||||
assert results.id == 1
|
||||
assert results.vector == [0.1, 0.2, 0.3]
|
||||
|
||||
executed_sql = [args[0] for args, _ in mock_conn.fetchall.call_args_list]
|
||||
assert any('SELECT "id" AS "id", "vector" AS "vector"' in sql for sql in executed_sql), (
|
||||
"Expected vector column to be selected when include_vectors=True"
|
||||
)
|
||||
|
||||
mock_conn.fetchall.reset_mock()
|
||||
mock_conn.fetchall.return_value = [(1, None)]
|
||||
results = await collection.get([1], include_vectors=False)
|
||||
assert results.id == 1
|
||||
assert results.vector is None
|
||||
|
||||
executed_sql = [args[0] for args, _ in mock_conn.fetchall.call_args_list]
|
||||
assert any('SELECT "id" AS "id", "vector" AS "vector"' not in sql for sql in executed_sql), (
|
||||
"Vector column should not be selected when include_vectors=False"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_and_get(oracle_store, mock_connection_pool):
|
||||
conn = AsyncMock()
|
||||
conn.description = [("id",), ("vector",)]
|
||||
conn.fetchall = AsyncMock(return_value=[(1, [0.1, 0.2, 0.3])])
|
||||
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = conn
|
||||
|
||||
collection = oracle_store.get_collection(SimpleModel, collection_name="TEST")
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
await collection.upsert(SimpleModel(id=1, vector=[0.1, 0.2, 0.3]))
|
||||
assert conn.executemany.await_count >= 1 or conn.execute.await_count >= 1, (
|
||||
"Expected upsert to call executemany() or execute()"
|
||||
)
|
||||
|
||||
results = await collection.get([1], include_vectors=True)
|
||||
|
||||
assert results.id == 1
|
||||
assert results.vector == [0.1, 0.2, 0.3]
|
||||
conn.fetchall.assert_awaited_once()
|
||||
conn.fetchall.reset_mock()
|
||||
conn.fetchall.return_value = [(1, None)]
|
||||
conn.description = [("id",)]
|
||||
|
||||
results = await collection.get([1], include_vectors=False)
|
||||
assert results.id == 1
|
||||
assert results.vector is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_record(oracle_store, mock_connection_pool):
|
||||
conn = AsyncMock()
|
||||
mock_connection_pool.acquire.return_value.__aenter__.return_value = conn
|
||||
|
||||
collection = oracle_store.get_collection(
|
||||
SimpleModel,
|
||||
collection_name="MY_COLLECTION",
|
||||
)
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
await collection.delete([1])
|
||||
conn.executemany.assert_awaited()
|
||||
called_sql = conn.executemany.call_args[0][0].lower()
|
||||
assert "delete" in called_sql
|
||||
assert "my_collection" in called_sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search(oracle_store, mock_connection_pool):
|
||||
class MockCursor:
|
||||
def __init__(self):
|
||||
self.execute_called_with = []
|
||||
self._rows = [(1, [0.1, 0.2, 0.3])]
|
||||
self.description = [SimpleNamespace(name="id"), SimpleNamespace(name="vector")]
|
||||
self._i = 0
|
||||
|
||||
async def execute(self, sql, binds=None):
|
||||
self.execute_called_with.append((sql, binds))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
return None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
return None
|
||||
|
||||
def __aiter__(self):
|
||||
self._i = 0
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self._i >= len(self._rows):
|
||||
raise StopAsyncIteration
|
||||
|
||||
r = self._rows[self._i]
|
||||
self._i += 1
|
||||
return r
|
||||
|
||||
mock_cursor = MockCursor()
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self, cur):
|
||||
self._cur = cur
|
||||
self.inputtypehandler = None
|
||||
self.outputtypehandler = None
|
||||
self.execute = AsyncMock()
|
||||
self.commit = AsyncMock()
|
||||
|
||||
def cursor(self):
|
||||
return self._cur
|
||||
|
||||
mock_conn = MockConnection(mock_cursor)
|
||||
|
||||
class MockAcquire:
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
def __await__(self):
|
||||
async def _():
|
||||
return self
|
||||
|
||||
return _().__await__()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self._conn
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
return None
|
||||
|
||||
mock_connection_pool.acquire = lambda **kwargs: MockAcquire(mock_conn)
|
||||
|
||||
collection = oracle_store.get_collection(
|
||||
model=SimpleModel, record_type=SimpleModel, collection_name="MY_COLLECTION"
|
||||
)
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
assert mock_conn.execute.await_count >= 1
|
||||
|
||||
ks_results = await collection.search(
|
||||
vector_property_name="vector",
|
||||
vector=[0.1, 0.2, 0.3],
|
||||
top=1,
|
||||
filter=lambda x: x.id in [1, 7, 9],
|
||||
include_vectors=True,
|
||||
)
|
||||
results = [r async for r in ks_results.results]
|
||||
|
||||
assert mock_cursor.execute_called_with, "cursor.execute was not called"
|
||||
sql, binds = mock_cursor.execute_called_with[0]
|
||||
assert "SELECT" in sql.upper()
|
||||
assert '"id", "vector", VECTOR_DISTANCE' in sql
|
||||
expected_where = 'WHERE "id" IN (:bind_val1, :bind_val2, :bind_val3)'
|
||||
assert expected_where in sql
|
||||
|
||||
assert binds is None or isinstance(binds[0], array)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].record.id == 1
|
||||
assert results[0].record.vector == [0.1, 0.2, 0.3]
|
||||
@@ -0,0 +1,337 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from pinecone import FetchResponse, IndexModel, Metric, QueryResponse, ServerlessSpec, Vector
|
||||
from pinecone.core.openapi.db_data.models import (
|
||||
Hit,
|
||||
ScoredVector,
|
||||
SearchRecordsResponse,
|
||||
SearchRecordsResponseResult,
|
||||
SearchUsage,
|
||||
)
|
||||
from pinecone.db_data.index_asyncio import _IndexAsyncio
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
from semantic_kernel.connectors.pinecone import PineconeCollection, PineconeStore
|
||||
from semantic_kernel.exceptions.vector_store_exceptions import VectorStoreInitializationException
|
||||
|
||||
BASE_PATH_ASYNCIO = "pinecone.PineconeAsyncio"
|
||||
BASE_PATH_INDEX_CLIENT_ASYNCIO = "pinecone.db_data.index_asyncio._IndexAsyncio"
|
||||
|
||||
|
||||
@fixture
|
||||
def embed(request) -> dict[str, Any] | None:
|
||||
if hasattr(request, "param"):
|
||||
return request.param
|
||||
return None
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_index_model(embed: dict[str, Any] | None):
|
||||
"""Mock IndexModel for testing."""
|
||||
mock_index_model = Mock(spec=IndexModel)
|
||||
mock_index_model.name = "test"
|
||||
mock_index_model.embed = embed
|
||||
mock_index_model.host = "test_host"
|
||||
return mock_index_model
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_list_collection_names(mock_index_model):
|
||||
with patch(f"{BASE_PATH_ASYNCIO}.list_indexes") as mock_list_indexes:
|
||||
mock_list_indexes.return_value = [mock_index_model]
|
||||
yield mock_list_indexes
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_create_index(mock_index_model):
|
||||
with patch(f"{BASE_PATH_ASYNCIO}.create_index") as mock_create_index:
|
||||
mock_create_index.return_value = mock_index_model
|
||||
yield mock_create_index
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_create_index_for_model(mock_index_model):
|
||||
with patch(f"{BASE_PATH_ASYNCIO}.create_index_for_model") as mock_create_index_for_model:
|
||||
mock_create_index_for_model.return_value = mock_index_model
|
||||
yield mock_create_index_for_model
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_describe_index(mock_index_model):
|
||||
with patch(f"{BASE_PATH_ASYNCIO}.describe_index") as mock_describe_index:
|
||||
mock_describe_index.return_value = mock_index_model
|
||||
yield mock_describe_index
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_has_index():
|
||||
with patch(f"{BASE_PATH_ASYNCIO}.has_index") as mock_has_index:
|
||||
mock_create_index.return_value = True
|
||||
yield mock_has_index
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_index_asyncio():
|
||||
mock_index_asyncio = AsyncMock(spec=_IndexAsyncio)
|
||||
mock_index_asyncio.close.return_value = None
|
||||
with patch(f"{BASE_PATH_ASYNCIO}.IndexAsyncio") as mock_index:
|
||||
mock_index.return_value = mock_index_asyncio
|
||||
yield mock_index
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_delete_index():
|
||||
with patch(f"{BASE_PATH_ASYNCIO}.delete_index") as mock_delete:
|
||||
yield mock_delete
|
||||
|
||||
|
||||
@fixture
|
||||
async def store(pinecone_unit_test_env) -> PineconeStore:
|
||||
"""Fixture to create a Pinecone store."""
|
||||
async with PineconeStore() as store:
|
||||
yield store
|
||||
|
||||
|
||||
@fixture
|
||||
async def collection(pinecone_unit_test_env, definition) -> PineconeCollection:
|
||||
"""Fixture to create a Pinecone store."""
|
||||
async with PineconeCollection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
) as collection:
|
||||
yield collection
|
||||
|
||||
|
||||
async def test_create_store(pinecone_unit_test_env):
|
||||
"""Test the creation of a Pinecone store."""
|
||||
# Create a Pinecone store
|
||||
store = PineconeStore()
|
||||
assert store is not None
|
||||
assert store.client is not None
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["PINECONE_API_KEY"]], indirect=True)
|
||||
async def test_create_store_fail(pinecone_unit_test_env):
|
||||
"""Test the creation of a Pinecone store."""
|
||||
with raises(VectorStoreInitializationException):
|
||||
PineconeStore(env_file_path="test.env")
|
||||
|
||||
|
||||
def test_create_store_grpc(pinecone_unit_test_env):
|
||||
"""Test the creation of a Pinecone store."""
|
||||
|
||||
# Create a Pinecone store
|
||||
store = PineconeStore(use_grpc=True)
|
||||
assert store is not None
|
||||
assert store.client is not None
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["PINECONE_API_KEY"]], indirect=True)
|
||||
async def test_ensure_collection_exists_fail(pinecone_unit_test_env, definition):
|
||||
with raises(VectorStoreInitializationException):
|
||||
PineconeCollection(
|
||||
collection_name="test_collection",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
async def test_get_collection(store: PineconeStore, definition):
|
||||
"""Test the creation of a Pinecone collection."""
|
||||
# Create a collection
|
||||
collection = store.get_collection(collection_name="test_collection", record_type=dict, definition=definition)
|
||||
assert collection is not None
|
||||
assert collection.collection_name == "test_collection"
|
||||
|
||||
|
||||
async def test_list_collection_names(store: PineconeStore):
|
||||
"""Test the listing of Pinecone collections."""
|
||||
# List collections
|
||||
collections = await store.list_collection_names()
|
||||
assert collections is not None
|
||||
assert len(collections) == 1
|
||||
assert collections[0] == "test"
|
||||
|
||||
|
||||
@mark.parametrize("embed", [None, {"model": "test-model"}])
|
||||
async def test_load_index_client(collection, mock_index_asyncio):
|
||||
# Test loading the index client
|
||||
await collection._load_index_client()
|
||||
assert collection.index is not None
|
||||
assert collection.index_client is not None
|
||||
assert isinstance(collection.index_client, _IndexAsyncio)
|
||||
assert collection.embed_settings == collection.index.embed
|
||||
|
||||
|
||||
async def test_ensure_collection_exists(collection, mock_create_index):
|
||||
await collection.ensure_collection_exists()
|
||||
assert collection.index is not None
|
||||
assert collection.index_client is not None
|
||||
mock_create_index.assert_awaited_once_with(
|
||||
name=collection.collection_name,
|
||||
spec=ServerlessSpec(cloud="aws", region="us-east-1"),
|
||||
dimension=5,
|
||||
metric=Metric.COSINE,
|
||||
vector_type="dense",
|
||||
)
|
||||
|
||||
|
||||
@mark.parametrize("embed", [{"model": "test-model"}])
|
||||
async def test_ensure_collection_exists_integrated(collection, mock_create_index_for_model):
|
||||
await collection.ensure_collection_exists(embed={"model": "test-model"})
|
||||
assert collection.index is not None
|
||||
assert collection.index_client is not None
|
||||
mock_create_index_for_model.assert_awaited_once_with(
|
||||
name=collection.collection_name,
|
||||
cloud="aws",
|
||||
region="us-east-1",
|
||||
embed={"model": "test-model", "metric": Metric.COSINE, "field_map": {"text": "vector"}},
|
||||
)
|
||||
|
||||
|
||||
async def test_ensure_collection_deleted(collection):
|
||||
# Test deleting the collection
|
||||
await collection.ensure_collection_deleted()
|
||||
assert collection.index is None
|
||||
assert collection.index_client is None
|
||||
|
||||
|
||||
async def test_upsert(collection):
|
||||
record = {
|
||||
"id": "test_id",
|
||||
"vector": [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
"content": "test_content",
|
||||
}
|
||||
pinecone_vector = Vector(values=record["vector"], id=record["id"], metadata={"content": record["content"]})
|
||||
await collection._load_index_client()
|
||||
with patch.object(collection.index_client, "upsert", new_callable=AsyncMock) as mock_upsert:
|
||||
await collection.upsert(record)
|
||||
mock_upsert.assert_awaited_once_with(
|
||||
[pinecone_vector],
|
||||
namespace=collection.namespace,
|
||||
)
|
||||
|
||||
|
||||
@mark.parametrize("embed", [{"model": "test-model"}])
|
||||
async def test_upsert_embed(collection):
|
||||
record = {
|
||||
"id": "test_id",
|
||||
"content": "test_content",
|
||||
"vector": [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
}
|
||||
await collection._load_index_client()
|
||||
with patch.object(collection.index_client, "upsert_records", new_callable=AsyncMock) as mock_upsert:
|
||||
await collection.upsert(record)
|
||||
mock_upsert.assert_awaited_once_with(
|
||||
records=[{"_id": record["id"], "content": record["content"]}],
|
||||
namespace=collection.namespace,
|
||||
)
|
||||
|
||||
|
||||
async def test_get(collection):
|
||||
record = {
|
||||
"id": "test_id",
|
||||
"vector": [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
"content": "test_content",
|
||||
}
|
||||
fetch_response = FetchResponse(
|
||||
namespace="",
|
||||
vectors={
|
||||
record["id"]: Vector(values=record["vector"], id=record["id"], metadata={"content": record["content"]})
|
||||
},
|
||||
usage={},
|
||||
)
|
||||
await collection._load_index_client()
|
||||
with patch.object(collection.index_client, "fetch", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = fetch_response
|
||||
get_record = await collection.get(record["id"])
|
||||
mock_fetch.assert_awaited_once_with(
|
||||
ids=[record["id"]],
|
||||
namespace=collection.namespace,
|
||||
)
|
||||
assert record["id"] == get_record["id"]
|
||||
assert record["content"] == get_record["content"]
|
||||
|
||||
|
||||
async def test_delete(collection):
|
||||
await collection._load_index_client()
|
||||
with patch.object(collection.index_client, "delete", new_callable=AsyncMock) as mock_delete:
|
||||
await collection.delete("test_id")
|
||||
mock_delete.assert_awaited_once_with(
|
||||
ids=["test_id"],
|
||||
namespace=collection.namespace,
|
||||
)
|
||||
|
||||
|
||||
async def test_search(collection):
|
||||
record = {
|
||||
"id": "test_id",
|
||||
"vector": [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
"content": "test_content",
|
||||
}
|
||||
query_response = QueryResponse._from_openapi_data(
|
||||
namespace="",
|
||||
matches=[
|
||||
ScoredVector(**{
|
||||
"values": record["vector"],
|
||||
"id": record["id"],
|
||||
"metadata": {"content": record["content"]},
|
||||
"score": 0.1,
|
||||
})
|
||||
],
|
||||
)
|
||||
await collection._load_index_client()
|
||||
with patch.object(collection.index_client, "query", new_callable=AsyncMock) as mock_query:
|
||||
mock_query.return_value = query_response
|
||||
query_response = await collection.search(
|
||||
vector=[0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
top=1,
|
||||
include_vectors=True,
|
||||
filter=lambda x: x.content == "test_content",
|
||||
)
|
||||
mock_query.assert_awaited_once_with(
|
||||
vector=[0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
top_k=1,
|
||||
include_metadata=True,
|
||||
include_values=True,
|
||||
namespace=collection.namespace,
|
||||
filter={"content": "test_content"},
|
||||
)
|
||||
assert query_response.total_count == 1
|
||||
async for result in query_response.results:
|
||||
assert result.record == record
|
||||
assert result.score == 0.1
|
||||
|
||||
|
||||
@mark.parametrize("embed", [{"model": "test-model"}])
|
||||
async def test_search_embed(collection):
|
||||
record = {"id": "test_id", "content": "test_content", "vector": None}
|
||||
query_response = SearchRecordsResponse._from_openapi_data(
|
||||
result=SearchRecordsResponseResult._from_openapi_data(**{
|
||||
"hits": [
|
||||
Hit(**{
|
||||
"_id": record["id"],
|
||||
"fields": {"id": record["id"], "content": record["content"]},
|
||||
"_score": 0.1,
|
||||
})
|
||||
]
|
||||
}),
|
||||
usage=SearchUsage(read_units=0),
|
||||
)
|
||||
await collection._load_index_client()
|
||||
with patch.object(collection.index_client, "search_records", new_callable=AsyncMock) as mock_query:
|
||||
mock_query.return_value = query_response
|
||||
query_response = await collection.search(values="test", top=1, include_vectors=True)
|
||||
mock_query.assert_awaited_once_with(
|
||||
query={"inputs": {"text": "test"}, "top_k": 1},
|
||||
namespace=collection.namespace,
|
||||
)
|
||||
assert query_response.total_count == 1
|
||||
async for result in query_response.results:
|
||||
assert result.record == record
|
||||
assert result.score == 0.1
|
||||
@@ -0,0 +1,415 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from psycopg import AsyncConnection, AsyncCursor
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
from pytest import fixture
|
||||
|
||||
from semantic_kernel.connectors.postgres import (
|
||||
DISTANCE_COLUMN_NAME,
|
||||
PostgresCollection,
|
||||
PostgresSettings,
|
||||
PostgresStore,
|
||||
)
|
||||
from semantic_kernel.data.vector import DistanceFunction, IndexKind, VectorStoreField, vectorstoremodel
|
||||
|
||||
|
||||
@fixture(scope="function")
|
||||
def mock_cursor():
|
||||
return AsyncMock(spec=AsyncCursor)
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_connection_pool(mock_cursor: Mock):
|
||||
with (
|
||||
patch(
|
||||
f"{AsyncConnectionPool.__module__}.{AsyncConnectionPool.__qualname__}.connection",
|
||||
) as mock_pool_connection,
|
||||
patch(
|
||||
f"{AsyncConnectionPool.__module__}.{AsyncConnectionPool.__qualname__}.open",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_pool_open,
|
||||
):
|
||||
mock_conn = AsyncMock(spec=AsyncConnection)
|
||||
|
||||
mock_pool_connection.return_value.__aenter__.return_value = mock_conn
|
||||
mock_conn.cursor.return_value.__aenter__.return_value = mock_cursor
|
||||
|
||||
mock_pool_open.return_value = None
|
||||
|
||||
yield mock_pool_connection, mock_pool_open
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def vector_store(postgres_unit_test_env) -> AsyncGenerator[PostgresStore, None]:
|
||||
async with await PostgresSettings(env_file_path="test.env").create_connection_pool() as pool:
|
||||
yield PostgresStore(connection_pool=pool)
|
||||
|
||||
|
||||
@vectorstoremodel
|
||||
@dataclass
|
||||
class SimpleDataModel:
|
||||
id: Annotated[int, VectorStoreField("key")]
|
||||
data: Annotated[
|
||||
list[float] | str | None,
|
||||
VectorStoreField(
|
||||
"vector",
|
||||
type="float",
|
||||
dimensions=1536,
|
||||
index_kind=IndexKind.HNSW,
|
||||
distance_function=DistanceFunction.COSINE_SIMILARITY,
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
# region VectorStore Tests
|
||||
|
||||
|
||||
async def test_vector_store_defaults(vector_store: PostgresStore) -> None:
|
||||
assert vector_store.connection_pool is not None
|
||||
async with vector_store.connection_pool.connection() as conn:
|
||||
assert isinstance(conn, Mock)
|
||||
|
||||
|
||||
def test_vector_store_with_connection_pool(vector_store: PostgresStore) -> None:
|
||||
connection_pool = MagicMock(spec=AsyncConnectionPool)
|
||||
vector_store = PostgresStore(connection_pool=connection_pool)
|
||||
assert vector_store.connection_pool == connection_pool
|
||||
|
||||
|
||||
async def test_list_collection_names(vector_store: PostgresStore, mock_cursor: Mock) -> None:
|
||||
mock_cursor.fetchall.return_value = [
|
||||
("test_collection",),
|
||||
("test_collection_2",),
|
||||
]
|
||||
names = await vector_store.list_collection_names()
|
||||
assert names == ["test_collection", "test_collection_2"]
|
||||
|
||||
|
||||
def test_get_collection(vector_store: PostgresStore) -> None:
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=SimpleDataModel)
|
||||
assert collection.collection_name == "test_collection"
|
||||
|
||||
|
||||
async def test_collection_exists(vector_store: PostgresStore, mock_cursor: Mock) -> None:
|
||||
mock_cursor.fetchall.return_value = [("test_collection",)]
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=SimpleDataModel)
|
||||
result = await collection.collection_exists()
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_ensure_collection_deleted(vector_store: PostgresStore, mock_cursor: Mock) -> None:
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=SimpleDataModel)
|
||||
await collection.ensure_collection_deleted()
|
||||
|
||||
assert mock_cursor.execute.call_count == 1
|
||||
execute_args, _ = mock_cursor.execute.call_args
|
||||
statement = execute_args[0]
|
||||
statement_str = statement.as_string()
|
||||
|
||||
assert statement_str == 'DROP TABLE "public"."test_collection" CASCADE'
|
||||
|
||||
|
||||
async def test_delete_records(vector_store: PostgresStore, mock_cursor: Mock) -> None:
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=SimpleDataModel)
|
||||
await collection.delete([1, 2])
|
||||
|
||||
assert mock_cursor.execute.call_count == 1
|
||||
execute_args, _ = mock_cursor.execute.call_args
|
||||
statement = execute_args[0]
|
||||
statement_str = statement.as_string()
|
||||
|
||||
assert statement_str == """DELETE FROM "public"."test_collection" WHERE "id" IN (1, 2)"""
|
||||
|
||||
|
||||
async def test_ensure_collection_exists_simple_model(vector_store: PostgresStore, mock_cursor: Mock) -> None:
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=SimpleDataModel)
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
# 2 calls, once for the table creation and once for the index creation
|
||||
assert mock_cursor.execute.call_count == 2
|
||||
|
||||
# Check the table creation statement
|
||||
execute_args, _ = mock_cursor.execute.call_args_list[0]
|
||||
statement = execute_args[0]
|
||||
statement_str = statement.as_string()
|
||||
assert statement_str == ('CREATE TABLE "public"."test_collection" ("id" INTEGER PRIMARY KEY, "data" VECTOR(1536))')
|
||||
|
||||
# Check the index creation statement
|
||||
execute_args, _ = mock_cursor.execute.call_args_list[1]
|
||||
statement = execute_args[0]
|
||||
statement_str = statement.as_string()
|
||||
assert statement_str == (
|
||||
'CREATE INDEX "test_collection_data_idx" ON "public"."test_collection" USING hnsw ("data" vector_cosine_ops)'
|
||||
)
|
||||
|
||||
|
||||
async def test_ensure_collection_exists_model_with_python_types(vector_store: PostgresStore, mock_cursor: Mock) -> None:
|
||||
@vectorstoremodel
|
||||
@dataclass
|
||||
class ModelWithImplicitTypes:
|
||||
name: Annotated[str, VectorStoreField("key")]
|
||||
age: Annotated[int, VectorStoreField("data")]
|
||||
data: Annotated[dict[str, Any], VectorStoreField("data")]
|
||||
embedding: Annotated[list[float], VectorStoreField("vector", dimensions=20)]
|
||||
scores: Annotated[list[float], VectorStoreField("data")]
|
||||
tags: Annotated[list[str], VectorStoreField("data")]
|
||||
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=ModelWithImplicitTypes)
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
assert mock_cursor.execute.call_count == 2
|
||||
|
||||
# Check the table creation statement
|
||||
execute_args, _ = mock_cursor.execute.call_args_list[0]
|
||||
statement = execute_args[0]
|
||||
statement_str = statement.as_string()
|
||||
assert statement_str == (
|
||||
'CREATE TABLE "public"."test_collection" '
|
||||
'("name" TEXT PRIMARY KEY, "age" INTEGER, "data" JSONB, '
|
||||
'"embedding" VECTOR(20), "scores" DOUBLE PRECISION[], "tags" TEXT[])'
|
||||
)
|
||||
|
||||
# Check the index creation statement
|
||||
execute_args, _ = mock_cursor.execute.call_args_list[1]
|
||||
statement = execute_args[0]
|
||||
statement_str = statement.as_string()
|
||||
assert statement_str == (
|
||||
'CREATE INDEX "test_collection_embedding_idx" ON "public"."test_collection" '
|
||||
'USING hnsw ("embedding" vector_cosine_ops)'
|
||||
)
|
||||
|
||||
|
||||
async def test_upsert_records(vector_store: PostgresStore, mock_cursor: Mock) -> None:
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=SimpleDataModel)
|
||||
await collection.upsert([
|
||||
SimpleDataModel(id=1, data=[1.0, 2.0, 3.0]),
|
||||
SimpleDataModel(id=2, data=[4.0, 5.0, 6.0]),
|
||||
SimpleDataModel(id=3, data=[5.0, 6.0, 1.0]),
|
||||
])
|
||||
|
||||
assert mock_cursor.executemany.call_count == 1
|
||||
execute_args, _ = mock_cursor.executemany.call_args
|
||||
statement_str = execute_args[0].as_string()
|
||||
values = execute_args[1]
|
||||
assert len(values) == 3
|
||||
|
||||
assert statement_str == (
|
||||
'INSERT INTO "public"."test_collection" ("id", "data") '
|
||||
"VALUES (%s, %s) "
|
||||
'ON CONFLICT ("id") DO UPDATE SET "data" = EXCLUDED."data"'
|
||||
)
|
||||
|
||||
assert values[0] == (1, [1.0, 2.0, 3.0])
|
||||
assert values[1] == (2, [4.0, 5.0, 6.0])
|
||||
assert values[2] == (3, [5.0, 6.0, 1.0])
|
||||
|
||||
|
||||
async def test_get_records(vector_store: PostgresStore, mock_cursor: Mock) -> None:
|
||||
mock_cursor.fetchall.return_value = [
|
||||
(1, "[1.0, 2.0, 3.0]", {"key": "value1"}),
|
||||
(2, "[4.0, 5.0, 6.0]", {"key": "value2"}),
|
||||
(3, "[5.0, 6.0, 1.0]", {"key": "value3"}),
|
||||
]
|
||||
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=SimpleDataModel)
|
||||
records = await collection.get([1, 2, 3])
|
||||
|
||||
assert len(records) == 3
|
||||
assert records[0].id == 1
|
||||
assert records[1].id == 2
|
||||
assert records[2].id == 3
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Vector Search tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"distance_function, operator, subquery_distance, include_vectors, include_total_count",
|
||||
[
|
||||
(DistanceFunction.COSINE_SIMILARITY, "<=>", f'1 - subquery."{DISTANCE_COLUMN_NAME}"', False, False),
|
||||
(DistanceFunction.COSINE_DISTANCE, "<=>", None, False, False),
|
||||
(DistanceFunction.DOT_PROD, "<#>", f'-1 * subquery."{DISTANCE_COLUMN_NAME}"', True, False),
|
||||
(DistanceFunction.EUCLIDEAN_DISTANCE, "<->", None, False, True),
|
||||
(DistanceFunction.MANHATTAN, "<+>", None, True, True),
|
||||
],
|
||||
)
|
||||
async def test_vector_search(
|
||||
vector_store: PostgresStore,
|
||||
mock_cursor: Mock,
|
||||
distance_function: DistanceFunction,
|
||||
operator: str,
|
||||
subquery_distance: str | None,
|
||||
include_vectors: bool,
|
||||
include_total_count: bool,
|
||||
) -> None:
|
||||
@vectorstoremodel
|
||||
@dataclass
|
||||
class SimpleDataModel:
|
||||
id: Annotated[int, VectorStoreField("key")]
|
||||
embedding: Annotated[
|
||||
list[float] | str | None,
|
||||
VectorStoreField(
|
||||
"vector",
|
||||
index_kind=IndexKind.HNSW,
|
||||
dimensions=1536,
|
||||
distance_function=distance_function,
|
||||
type="float",
|
||||
),
|
||||
]
|
||||
data: Annotated[
|
||||
dict[str, Any],
|
||||
VectorStoreField("data", type="JSONB"),
|
||||
]
|
||||
|
||||
def model_post_init(self, context: Any) -> None:
|
||||
if self.embedding is None:
|
||||
self.embedding = self.data
|
||||
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=SimpleDataModel)
|
||||
assert isinstance(collection, PostgresCollection)
|
||||
|
||||
search_results = await collection.search(
|
||||
vector=[1.0, 2.0, 3.0],
|
||||
top=10,
|
||||
skip=5,
|
||||
include_vectors=include_vectors,
|
||||
include_total_count=include_total_count,
|
||||
)
|
||||
if include_total_count:
|
||||
# Including total count issues query directly
|
||||
assert mock_cursor.execute.call_count == 1
|
||||
else:
|
||||
# Total count is not included, query is issued when iterating over results
|
||||
assert mock_cursor.execute.call_count == 0
|
||||
async for _ in search_results.results:
|
||||
pass
|
||||
assert mock_cursor.execute.call_count == 1
|
||||
|
||||
execute_args, _ = mock_cursor.execute.call_args
|
||||
|
||||
assert (search_results.total_count is not None) == include_total_count
|
||||
|
||||
statement = execute_args[0]
|
||||
statement_str = statement.as_string()
|
||||
|
||||
expected_columns = '"id", "data"'
|
||||
if include_vectors:
|
||||
expected_columns = '"id", "embedding", "data"'
|
||||
|
||||
expected_statement = (
|
||||
f'SELECT {expected_columns}, "embedding" {operator} %s as "{DISTANCE_COLUMN_NAME}" '
|
||||
'FROM "public"."test_collection" '
|
||||
f'ORDER BY "{DISTANCE_COLUMN_NAME}" LIMIT 10 OFFSET 5'
|
||||
)
|
||||
|
||||
if subquery_distance:
|
||||
expected_statement = (
|
||||
f'SELECT subquery.*, {subquery_distance} AS "{DISTANCE_COLUMN_NAME}" FROM ('
|
||||
+ expected_statement
|
||||
+ ") AS subquery"
|
||||
)
|
||||
|
||||
assert statement_str == expected_statement
|
||||
|
||||
|
||||
async def test_model_post_init_conflicting_distance_column_name(vector_store: PostgresStore) -> None:
|
||||
@vectorstoremodel
|
||||
@dataclass
|
||||
class ConflictingDataModel:
|
||||
id: Annotated[int, VectorStoreField("key")]
|
||||
sk_pg_distance: Annotated[
|
||||
float, VectorStoreField("data")
|
||||
] # Note: test depends on value of DISTANCE_COLUMN_NAME constant
|
||||
|
||||
embedding: Annotated[
|
||||
list[float],
|
||||
VectorStoreField(
|
||||
"vector",
|
||||
index_kind=IndexKind.HNSW,
|
||||
dimensions=1536,
|
||||
distance_function=DistanceFunction.COSINE_SIMILARITY,
|
||||
type="float",
|
||||
),
|
||||
]
|
||||
data: Annotated[
|
||||
dict[str, Any],
|
||||
VectorStoreField("data", type="JSONB"),
|
||||
]
|
||||
|
||||
collection = vector_store.get_collection(collection_name="test_collection", record_type=ConflictingDataModel)
|
||||
assert isinstance(collection, PostgresCollection)
|
||||
|
||||
# Ensure that the distance column name has been changed to avoid conflict
|
||||
assert collection._distance_column_name != DISTANCE_COLUMN_NAME
|
||||
assert collection._distance_column_name.startswith(f"{DISTANCE_COLUMN_NAME}_")
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Settings tests
|
||||
|
||||
|
||||
def test_settings_connection_string(monkeypatch) -> None:
|
||||
monkeypatch.delenv("PGHOST", raising=False)
|
||||
monkeypatch.delenv("PGPORT", raising=False)
|
||||
monkeypatch.delenv("PGDATABASE", raising=False)
|
||||
monkeypatch.delenv("PGUSER", raising=False)
|
||||
monkeypatch.delenv("PGPASSWORD", raising=False)
|
||||
|
||||
settings = PostgresSettings(connection_string="host=localhost port=5432 dbname=dbname user=user password=password")
|
||||
conn_info = settings.get_connection_args()
|
||||
|
||||
assert conn_info["host"] == "localhost"
|
||||
assert conn_info["port"] == 5432
|
||||
assert conn_info["dbname"] == "dbname"
|
||||
assert conn_info["user"] == "user"
|
||||
assert conn_info["password"] == "password"
|
||||
|
||||
|
||||
def test_settings_env_connection_string(monkeypatch) -> None:
|
||||
monkeypatch.delenv("PGHOST", raising=False)
|
||||
monkeypatch.delenv("PGPORT", raising=False)
|
||||
monkeypatch.delenv("PGDATABASE", raising=False)
|
||||
monkeypatch.delenv("PGUSER", raising=False)
|
||||
monkeypatch.delenv("PGPASSWORD", raising=False)
|
||||
|
||||
monkeypatch.setenv(
|
||||
"POSTGRES_CONNECTION_STRING", "host=localhost port=5432 dbname=dbname user=user password=password"
|
||||
)
|
||||
|
||||
settings = PostgresSettings()
|
||||
conn_info = settings.get_connection_args()
|
||||
assert conn_info["host"] == "localhost"
|
||||
assert conn_info["port"] == 5432
|
||||
assert conn_info["dbname"] == "dbname"
|
||||
assert conn_info["user"] == "user"
|
||||
assert conn_info["password"] == "password"
|
||||
|
||||
|
||||
def test_settings_env_vars(monkeypatch) -> None:
|
||||
monkeypatch.setenv("PGHOST", "localhost")
|
||||
monkeypatch.setenv("PGPORT", "5432")
|
||||
monkeypatch.setenv("PGDATABASE", "dbname")
|
||||
monkeypatch.setenv("PGUSER", "user")
|
||||
monkeypatch.setenv("PGPASSWORD", "password")
|
||||
|
||||
settings = PostgresSettings()
|
||||
conn_info = settings.get_connection_args()
|
||||
assert conn_info["host"] == "localhost"
|
||||
assert conn_info["port"] == 5432
|
||||
assert conn_info["dbname"] == "dbname"
|
||||
assert conn_info["user"] == "user"
|
||||
assert conn_info["password"] == "password"
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,341 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pytest import fixture, mark, raises
|
||||
from qdrant_client.async_qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.models import Datatype, Distance, FieldCondition, MatchValue, VectorParams
|
||||
|
||||
from semantic_kernel.connectors.qdrant import QdrantCollection, QdrantStore
|
||||
from semantic_kernel.data.vector import DistanceFunction, VectorStoreField
|
||||
from semantic_kernel.exceptions import (
|
||||
VectorSearchExecutionException,
|
||||
VectorStoreInitializationException,
|
||||
VectorStoreModelValidationError,
|
||||
VectorStoreOperationException,
|
||||
)
|
||||
|
||||
BASE_PATH = "qdrant_client.async_qdrant_client.AsyncQdrantClient"
|
||||
|
||||
|
||||
@fixture
|
||||
def vector_store(qdrant_unit_test_env):
|
||||
return QdrantStore(env_file_path="test.env")
|
||||
|
||||
|
||||
@fixture
|
||||
def collection(qdrant_unit_test_env, definition):
|
||||
return QdrantCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@fixture
|
||||
def collection_without_named_vectors(qdrant_unit_test_env, definition):
|
||||
return QdrantCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
named_vectors=False,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_list_collection_names():
|
||||
with patch(f"{BASE_PATH}.get_collections") as mock_get_collections:
|
||||
from qdrant_client.conversions.common_types import CollectionsResponse
|
||||
from qdrant_client.http.models import CollectionDescription
|
||||
|
||||
response = MagicMock(spec=CollectionsResponse)
|
||||
response.collections = [CollectionDescription(name="test")]
|
||||
mock_get_collections.return_value = response
|
||||
yield mock_get_collections
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_collection_exists():
|
||||
with patch(f"{BASE_PATH}.collection_exists") as mock_collection_exists:
|
||||
mock_collection_exists.return_value = True
|
||||
yield mock_collection_exists
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_ensure_collection_exists():
|
||||
with patch(f"{BASE_PATH}.create_collection") as mock_ensure_collection_exists:
|
||||
yield mock_ensure_collection_exists
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_ensure_collection_deleted():
|
||||
with patch(f"{BASE_PATH}.delete_collection") as mock_ensure_collection_deleted:
|
||||
mock_ensure_collection_deleted.return_value = True
|
||||
yield mock_ensure_collection_deleted
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_upsert():
|
||||
with patch(f"{BASE_PATH}.upsert") as mock_upsert:
|
||||
from qdrant_client.conversions.common_types import UpdateResult
|
||||
|
||||
result = MagicMock(spec=UpdateResult)
|
||||
result.status = "completed"
|
||||
mock_upsert.return_value = result
|
||||
yield mock_upsert
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_get(collection):
|
||||
with patch(f"{BASE_PATH}.retrieve") as mock_retrieve:
|
||||
from qdrant_client.http.models import Record
|
||||
|
||||
if collection.named_vectors:
|
||||
mock_retrieve.return_value = [
|
||||
Record(id="id1", payload={"content": "content"}, vector={"vector": [1.0, 2.0, 3.0]})
|
||||
]
|
||||
else:
|
||||
mock_retrieve.return_value = [Record(id="id1", payload={"content": "content"}, vector=[1.0, 2.0, 3.0])]
|
||||
yield mock_retrieve
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_delete():
|
||||
with patch(f"{BASE_PATH}.delete") as mock_delete:
|
||||
yield mock_delete
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_search():
|
||||
with patch(f"{BASE_PATH}.search") as mock_search:
|
||||
from qdrant_client.models import ScoredPoint
|
||||
|
||||
response1 = ScoredPoint(id="id1", version=1, score=0.0, payload={"content": "content"})
|
||||
response2 = ScoredPoint(id="id2", version=1, score=0.0, payload={"content": "content"})
|
||||
mock_search.return_value = [response1, response2]
|
||||
yield mock_search
|
||||
|
||||
|
||||
async def test_vector_store_defaults(vector_store):
|
||||
async with vector_store:
|
||||
assert vector_store.qdrant_client is not None
|
||||
assert vector_store.qdrant_client._client.rest_uri == "http://localhost:6333"
|
||||
|
||||
|
||||
def test_vector_store_with_client():
|
||||
qdrant_store = QdrantStore(client=AsyncQdrantClient())
|
||||
assert qdrant_store.qdrant_client is not None
|
||||
assert qdrant_store.qdrant_client._client.rest_uri == "http://localhost:6333"
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["QDRANT_LOCATION"]], indirect=True)
|
||||
def test_vector_store_in_memory(qdrant_unit_test_env):
|
||||
from qdrant_client.local.async_qdrant_local import AsyncQdrantLocal
|
||||
|
||||
qdrant_store = QdrantStore(api_key="supersecretkey", env_file_path="test.env")
|
||||
assert qdrant_store.qdrant_client is not None
|
||||
assert isinstance(qdrant_store.qdrant_client._client, AsyncQdrantLocal)
|
||||
assert qdrant_store.qdrant_client._client.location == ":memory:"
|
||||
|
||||
|
||||
def test_vector_store_fail():
|
||||
with raises(VectorStoreInitializationException, match="Failed to create Qdrant settings."):
|
||||
QdrantStore(location="localhost", url="localhost", env_file_path="test.env")
|
||||
|
||||
with raises(VectorStoreInitializationException, match="Failed to create Qdrant client."):
|
||||
QdrantStore(location="localhost", url="http://localhost", env_file_path="test.env")
|
||||
|
||||
|
||||
async def test_store_list_collection_names(vector_store):
|
||||
collections = await vector_store.list_collection_names()
|
||||
assert collections == ["test"]
|
||||
|
||||
|
||||
def test_get_collection(vector_store: QdrantStore, definition, qdrant_unit_test_env):
|
||||
collection = vector_store.get_collection(collection_name="test", record_type=dict, definition=definition)
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.qdrant_client == vector_store.qdrant_client
|
||||
assert collection.record_type is dict
|
||||
assert collection.definition == definition
|
||||
|
||||
|
||||
async def test_collection_init(definition, qdrant_unit_test_env):
|
||||
async with QdrantCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
) as collection:
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.qdrant_client is not None
|
||||
assert collection.record_type is dict
|
||||
assert collection.definition == definition
|
||||
assert collection.named_vectors
|
||||
|
||||
|
||||
def test_collection_init_fail(definition):
|
||||
with raises(VectorStoreInitializationException, match="Failed to create Qdrant settings."):
|
||||
QdrantCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
url="localhost",
|
||||
env_file_path="test.env",
|
||||
)
|
||||
with raises(VectorStoreInitializationException, match="Failed to create Qdrant client."):
|
||||
QdrantCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
location="localhost",
|
||||
url="http://localhost",
|
||||
env_file_path="test.env",
|
||||
)
|
||||
with raises(
|
||||
VectorStoreModelValidationError, match="Only one vector field is allowed when not using named vectors."
|
||||
):
|
||||
definition.fields.append(VectorStoreField("vector", name="vector2", dimensions=3))
|
||||
QdrantCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
named_vectors=False,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@mark.parametrize("collection_to_use", ["collection", "collection_without_named_vectors"])
|
||||
async def test_upsert(collection_to_use, request):
|
||||
from qdrant_client.models import PointStruct
|
||||
|
||||
collection = request.getfixturevalue(collection_to_use)
|
||||
if collection.named_vectors:
|
||||
record = PointStruct(id="id1", payload={"content": "content"}, vector={"vector": [1.0, 2.0, 3.0]})
|
||||
else:
|
||||
record = PointStruct(id="id1", payload={"content": "content"}, vector=[1.0, 2.0, 3.0])
|
||||
ids = await collection._inner_upsert([record])
|
||||
assert ids[0] == "id1"
|
||||
|
||||
ids = await collection.upsert(records={"id": "id1", "content": "content", "vector": [1.0, 2.0, 3.0]})
|
||||
assert ids == "id1"
|
||||
|
||||
|
||||
async def test_get(collection):
|
||||
records = await collection._inner_get(["id1"])
|
||||
assert records is not None
|
||||
|
||||
records = await collection.get("id1")
|
||||
assert records is not None
|
||||
|
||||
|
||||
async def test_delete(collection):
|
||||
await collection._inner_delete(["id1"])
|
||||
|
||||
|
||||
async def test_collection_exists(collection):
|
||||
await collection.collection_exists()
|
||||
|
||||
|
||||
async def test_ensure_collection_deleted(collection):
|
||||
await collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"collection_to_use, results",
|
||||
[
|
||||
(
|
||||
"collection",
|
||||
{
|
||||
"collection_name": "test",
|
||||
"vectors_config": {"vector": VectorParams(size=5, distance=Distance.COSINE, datatype=Datatype.FLOAT32)},
|
||||
},
|
||||
),
|
||||
(
|
||||
"collection_without_named_vectors",
|
||||
{
|
||||
"collection_name": "test",
|
||||
"vectors_config": VectorParams(size=5, distance=Distance.COSINE, datatype=Datatype.FLOAT32),
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_create_index_with_named_vectors(collection_to_use, results, mock_ensure_collection_exists, request):
|
||||
await request.getfixturevalue(collection_to_use).ensure_collection_exists()
|
||||
mock_ensure_collection_exists.assert_called_once_with(**results)
|
||||
|
||||
|
||||
@mark.parametrize("collection_to_use", ["collection", "collection_without_named_vectors"])
|
||||
async def test_create_index_fail(collection_to_use, request):
|
||||
collection = request.getfixturevalue(collection_to_use)
|
||||
for field in collection.definition.vector_fields:
|
||||
field.distance_function = DistanceFunction.HAMMING
|
||||
with raises(VectorStoreOperationException):
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
|
||||
async def test_search(collection, mock_search):
|
||||
collection.named_vectors = False
|
||||
results = await collection.search(vector=[1.0, 2.0, 3.0], include_vectors=False)
|
||||
async for result in results.results:
|
||||
assert result.record["id"] == "id1"
|
||||
break
|
||||
|
||||
assert mock_search.call_count == 1
|
||||
mock_search.assert_called_with(
|
||||
collection_name="test",
|
||||
query_vector=[1.0, 2.0, 3.0],
|
||||
query_filter=None,
|
||||
with_vectors=False,
|
||||
limit=3,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
|
||||
async def test_search_named_vectors(collection, mock_search):
|
||||
collection.named_vectors = True
|
||||
results = await collection.search(
|
||||
vector=[1.0, 2.0, 3.0],
|
||||
vector_property_name="vector",
|
||||
include_vectors=False,
|
||||
)
|
||||
async for result in results.results:
|
||||
assert result.record["id"] == "id1"
|
||||
break
|
||||
|
||||
assert mock_search.call_count == 1
|
||||
mock_search.assert_called_with(
|
||||
collection_name="test",
|
||||
query_vector=("vector", [1.0, 2.0, 3.0]),
|
||||
query_filter=None,
|
||||
with_vectors=False,
|
||||
limit=3,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
|
||||
async def test_search_filter(collection, mock_search):
|
||||
results = await collection.search(
|
||||
vector=[1.0, 2.0, 3.0],
|
||||
include_vectors=False,
|
||||
filter=lambda x: x.id == "id1",
|
||||
)
|
||||
async for result in results.results:
|
||||
assert result.record["id"] == "id1"
|
||||
break
|
||||
|
||||
assert mock_search.call_count == 1
|
||||
mock_search.assert_called_with(
|
||||
collection_name="test",
|
||||
query_vector=("vector", [1.0, 2.0, 3.0]),
|
||||
query_filter=FieldCondition(key="id", match=MatchValue(value="id1")),
|
||||
with_vectors=False,
|
||||
limit=3,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
|
||||
async def test_search_fail(collection):
|
||||
with raises(VectorSearchExecutionException, match="Search requires a vector."):
|
||||
await collection.search(include_vectors=False)
|
||||
@@ -0,0 +1,308 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import numpy as np
|
||||
from pytest import fixture, mark, raises
|
||||
from redis.asyncio.client import Redis
|
||||
|
||||
from semantic_kernel.connectors.redis import (
|
||||
RedisCollectionTypes,
|
||||
RedisHashsetCollection,
|
||||
RedisJsonCollection,
|
||||
RedisStore,
|
||||
)
|
||||
from semantic_kernel.exceptions import VectorStoreInitializationException, VectorStoreOperationException
|
||||
|
||||
BASE_PATH = "redis.asyncio.client.Redis"
|
||||
BASE_PATH_FT = "redis.commands.search.AsyncSearch"
|
||||
BASE_PATH_JSON = "redis.commands.json.commands.JSONCommands"
|
||||
|
||||
|
||||
@fixture
|
||||
def vector_store(redis_unit_test_env):
|
||||
return RedisStore(env_file_path="test.env")
|
||||
|
||||
|
||||
@fixture
|
||||
def collection_hash(redis_unit_test_env, definition):
|
||||
return RedisHashsetCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@fixture
|
||||
def collection_json(redis_unit_test_env, definition):
|
||||
return RedisJsonCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@fixture
|
||||
def collection_with_prefix_hash(redis_unit_test_env, definition):
|
||||
return RedisHashsetCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
prefix_collection_name_to_key_names=True,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@fixture
|
||||
def collection_with_prefix_json(redis_unit_test_env, definition):
|
||||
return RedisJsonCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
prefix_collection_name_to_key_names=True,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def moc_list_collection_names():
|
||||
with patch(f"{BASE_PATH}.execute_command") as mock_get_collections:
|
||||
mock_get_collections.return_value = [b"test"]
|
||||
yield mock_get_collections
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_collection_exists():
|
||||
with patch(f"{BASE_PATH_FT}.info", new=AsyncMock()) as mock_collection_exists:
|
||||
mock_collection_exists.return_value = True
|
||||
yield mock_collection_exists
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_ensure_collection_exists():
|
||||
with patch(f"{BASE_PATH_FT}.create_index", new=AsyncMock()) as mock_reensure_collection_exists:
|
||||
yield mock_reensure_collection_exists
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_ensure_collection_deleted():
|
||||
with patch(f"{BASE_PATH_FT}.dropindex", new=AsyncMock()) as mock_ensure_collection_deleted:
|
||||
yield mock_ensure_collection_deleted
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_upsert_hash():
|
||||
with patch(f"{BASE_PATH}.hset", new=AsyncMock()) as mock_upsert:
|
||||
yield mock_upsert
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_upsert_json():
|
||||
with patch(f"{BASE_PATH_JSON}.set", new=AsyncMock()) as mock_upsert:
|
||||
yield mock_upsert
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_get_hash():
|
||||
with patch(f"{BASE_PATH}.hgetall", new=AsyncMock()) as mock_get:
|
||||
mock_get.return_value = {
|
||||
b"content": b"content",
|
||||
b"vector": np.array([1.0, 2.0, 3.0]).tobytes(),
|
||||
}
|
||||
yield mock_get
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_get_json():
|
||||
with patch(f"{BASE_PATH_JSON}.mget", new=AsyncMock()) as mock_get:
|
||||
mock_get.return_value = [
|
||||
[
|
||||
{
|
||||
"content": "content",
|
||||
"vector": [1.0, 2.0, 3.0],
|
||||
}
|
||||
]
|
||||
]
|
||||
yield mock_get
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_delete_hash():
|
||||
with patch(f"{BASE_PATH}.delete", new=AsyncMock()) as mock_delete:
|
||||
yield mock_delete
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
def mock_delete_json():
|
||||
with patch(f"{BASE_PATH_JSON}.delete", new=AsyncMock()) as mock_delete:
|
||||
yield mock_delete
|
||||
|
||||
|
||||
def test_vector_store_defaults(vector_store):
|
||||
assert vector_store.redis_database is not None
|
||||
assert vector_store.redis_database.connection_pool.connection_kwargs["host"] == "localhost"
|
||||
|
||||
|
||||
def test_vector_store_with_client(redis_unit_test_env):
|
||||
vector_store = RedisStore(redis_database=Redis.from_url(redis_unit_test_env["REDIS_CONNECTION_STRING"]))
|
||||
assert vector_store.redis_database is not None
|
||||
assert vector_store.redis_database.connection_pool.connection_kwargs["host"] == "localhost"
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["REDIS_CONNECTION_STRING"]], indirect=True)
|
||||
def test_vector_store_fail(redis_unit_test_env):
|
||||
with raises(VectorStoreInitializationException, match="Failed to create Redis settings."):
|
||||
RedisStore(env_file_path="test.env")
|
||||
|
||||
|
||||
async def test_store_list_collection_names(vector_store, moc_list_collection_names):
|
||||
collections = await vector_store.list_collection_names()
|
||||
assert collections == ["test"]
|
||||
|
||||
|
||||
@mark.parametrize("type_", ["hashset", "json"])
|
||||
def test_get_collection(vector_store, definition, type_):
|
||||
if type_ == "hashset":
|
||||
collection = vector_store.get_collection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
collection_type=RedisCollectionTypes.HASHSET,
|
||||
)
|
||||
assert isinstance(collection, RedisHashsetCollection)
|
||||
else:
|
||||
collection = vector_store.get_collection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
collection_type=RedisCollectionTypes.JSON,
|
||||
)
|
||||
assert isinstance(collection, RedisJsonCollection)
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.redis_database == vector_store.redis_database
|
||||
assert collection.record_type is dict
|
||||
assert collection.definition == definition
|
||||
|
||||
|
||||
@mark.parametrize("type_", ["hashset", "json"])
|
||||
def test_collection_init(redis_unit_test_env, definition, type_):
|
||||
if type_ == "hashset":
|
||||
collection = RedisHashsetCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
else:
|
||||
collection = RedisJsonCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.redis_database is not None
|
||||
assert collection.record_type is dict
|
||||
assert collection.definition == definition
|
||||
assert collection.prefix_collection_name_to_key_names is False
|
||||
|
||||
|
||||
@mark.parametrize("type_", ["hashset", "json"])
|
||||
def test_init_with_type(redis_unit_test_env, record_type, type_):
|
||||
if type_ == "hashset":
|
||||
collection = RedisHashsetCollection(record_type=record_type, collection_name="test")
|
||||
else:
|
||||
collection = RedisJsonCollection(record_type=record_type, collection_name="test")
|
||||
assert collection is not None
|
||||
assert collection.record_type is record_type
|
||||
assert collection.collection_name == "test"
|
||||
|
||||
|
||||
@mark.parametrize("exclude_list", [["REDIS_CONNECTION_STRING"]], indirect=True)
|
||||
def test_collection_fail(redis_unit_test_env, definition):
|
||||
with raises(VectorStoreInitializationException, match="Failed to create Redis settings."):
|
||||
RedisHashsetCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
with raises(VectorStoreInitializationException, match="Failed to create Redis settings."):
|
||||
RedisJsonCollection(
|
||||
record_type=dict,
|
||||
collection_name="test",
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
|
||||
@mark.parametrize("type_", ["hashset", "json"])
|
||||
async def test_upsert(collection_hash, collection_json, type_):
|
||||
collection = collection_hash if type_ == "hashset" else collection_json
|
||||
ids = await collection.upsert(records={"id": "id1", "content": "content", "vector": [1.0, 2.0, 3.0]})
|
||||
assert ids == "id1"
|
||||
|
||||
|
||||
async def test_upsert_with_prefix(collection_with_prefix_hash, collection_with_prefix_json):
|
||||
ids = await collection_with_prefix_hash.upsert(
|
||||
records={"id": "id1", "content": "content", "vector": [1.0, 2.0, 3.0]}
|
||||
)
|
||||
assert ids == "id1"
|
||||
ids = await collection_with_prefix_json.upsert(
|
||||
records={"id": "id1", "content": "content", "vector": [1.0, 2.0, 3.0]}
|
||||
)
|
||||
assert ids == "id1"
|
||||
|
||||
|
||||
@mark.parametrize("prefix", [True, False])
|
||||
@mark.parametrize("type_", ["hashset", "json"])
|
||||
async def test_get(
|
||||
collection_hash, collection_json, collection_with_prefix_hash, collection_with_prefix_json, type_, prefix
|
||||
):
|
||||
if prefix:
|
||||
collection = collection_with_prefix_hash if type_ == "hashset" else collection_with_prefix_json
|
||||
else:
|
||||
collection = collection_hash if type_ == "hashset" else collection_json
|
||||
|
||||
records = await collection.get("id1")
|
||||
assert records is not None
|
||||
|
||||
|
||||
@mark.parametrize("type_", ["hashset", "json"])
|
||||
async def test_delete(collection_hash, collection_json, type_):
|
||||
collection = collection_hash if type_ == "hashset" else collection_json
|
||||
await collection._inner_delete(["id1"])
|
||||
|
||||
|
||||
async def test_collection_exists(collection_hash, mock_collection_exists):
|
||||
await collection_hash.collection_exists()
|
||||
|
||||
|
||||
async def test_collection_exists_false(collection_hash, mock_collection_exists):
|
||||
mock_collection_exists.side_effect = Exception
|
||||
exists = await collection_hash.collection_exists()
|
||||
assert not exists
|
||||
|
||||
|
||||
async def test_ensure_collection_deleted(collection_hash, mock_ensure_collection_deleted):
|
||||
await collection_hash.ensure_collection_deleted()
|
||||
await collection_hash.ensure_collection_deleted()
|
||||
|
||||
|
||||
async def test_create_index(collection_hash, mock_ensure_collection_exists):
|
||||
await collection_hash.ensure_collection_exists()
|
||||
|
||||
|
||||
async def test_create_index_manual(collection_hash, mock_ensure_collection_exists):
|
||||
from redis.commands.search.index_definition import IndexDefinition, IndexType
|
||||
|
||||
fields = ["fields"]
|
||||
index_definition = IndexDefinition(prefix="test:", index_type=IndexType.HASH)
|
||||
await collection_hash.ensure_collection_exists(index_definition=index_definition, fields=fields)
|
||||
|
||||
|
||||
async def test_create_index_fail(collection_hash, mock_ensure_collection_exists):
|
||||
with raises(VectorStoreOperationException, match="Invalid index type supplied."):
|
||||
await collection_hash.ensure_collection_exists(index_definition="index_definition", fields="fields")
|
||||
@@ -0,0 +1,601 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import NamedTuple
|
||||
from unittest.mock import AsyncMock, MagicMock, NonCallableMagicMock, patch
|
||||
|
||||
from pytest import fixture, mark, param, raises
|
||||
|
||||
from semantic_kernel.connectors.sql_server import (
|
||||
QueryBuilder,
|
||||
SqlCommand,
|
||||
SqlServerCollection,
|
||||
SqlServerStore,
|
||||
_build_create_table_query,
|
||||
_build_delete_query,
|
||||
_build_delete_table_query,
|
||||
_build_merge_query,
|
||||
_build_search_query,
|
||||
_build_select_query,
|
||||
_build_select_table_names_query,
|
||||
)
|
||||
from semantic_kernel.data.vector import DistanceFunction, IndexKind, VectorSearchOptions, VectorStoreField
|
||||
from semantic_kernel.exceptions.vector_store_exceptions import (
|
||||
VectorStoreInitializationException,
|
||||
VectorStoreOperationException,
|
||||
)
|
||||
|
||||
|
||||
class TestQueryBuilder:
|
||||
def test_query_builder_append(self):
|
||||
qb = QueryBuilder()
|
||||
qb.append("SELECT * FROM")
|
||||
qb.append(" table", suffix=";")
|
||||
result = str(qb).strip()
|
||||
assert result == "SELECT * FROM table;"
|
||||
|
||||
def test_query_builder_append_list(self):
|
||||
qb = QueryBuilder()
|
||||
qb.append_list(["id", "name", "age"], sep=", ", suffix=";")
|
||||
result = str(qb).strip()
|
||||
assert result == "id, name, age;"
|
||||
|
||||
def test_query_builder_escape_identifier(self):
|
||||
assert QueryBuilder.escape_identifier("simple") == "[simple]"
|
||||
assert QueryBuilder.escape_identifier("has]bracket") == "[has]]bracket]"
|
||||
assert QueryBuilder.escape_identifier("two]]brackets") == "[two]]]]brackets]"
|
||||
assert QueryBuilder.escape_identifier("") == "[]"
|
||||
|
||||
def test_query_builder_append_table_name(self):
|
||||
qb = QueryBuilder()
|
||||
qb.append_table_name("dbo", "Users", prefix="SELECT * FROM", suffix=";", newline=False)
|
||||
result = str(qb).strip()
|
||||
assert result == "SELECT * FROM [dbo].[Users] ;"
|
||||
|
||||
def test_query_builder_append_table_name_escapes_closing_bracket(self):
|
||||
qb = QueryBuilder()
|
||||
qb.append_table_name("my]schema", "my]table", prefix="SELECT * FROM", suffix=";")
|
||||
result = str(qb).strip()
|
||||
assert result == "SELECT * FROM [my]]schema].[my]]table] ;"
|
||||
|
||||
def test_query_builder_append_table_name_prevents_sql_injection(self):
|
||||
qb = QueryBuilder()
|
||||
qb.append("DROP TABLE IF EXISTS")
|
||||
qb.append_table_name("dbo", "]; EXEC xp_cmdshell('whoami'); --", suffix=";")
|
||||
result = str(qb)
|
||||
assert "EXEC xp_cmdshell" not in result.split("].[")[0], "SQL injection should not escape bracket quoting"
|
||||
assert "[dbo].[]]; EXEC xp_cmdshell('whoami'); --]" in result
|
||||
|
||||
def test_query_builder_remove_last(self):
|
||||
qb = QueryBuilder("SELECT * FROM table;")
|
||||
qb.remove_last(1) # remove trailing semicolon
|
||||
result = str(qb).strip()
|
||||
assert result == "SELECT * FROM table"
|
||||
|
||||
def test_query_builder_in_parenthesis(self):
|
||||
qb = QueryBuilder("INSERT INTO table")
|
||||
with qb.in_parenthesis():
|
||||
qb.append("id, name, age")
|
||||
result = str(qb).strip()
|
||||
assert result == "INSERT INTO table (id, name, age)"
|
||||
|
||||
def test_query_builder_in_parenthesis_with_prefix_suffix(self):
|
||||
qb = QueryBuilder()
|
||||
with qb.in_parenthesis(prefix="VALUES", suffix=";"):
|
||||
qb.append_list(["1", "'John'", "30"])
|
||||
result = str(qb).strip()
|
||||
assert result == "VALUES (1, 'John', 30) ;"
|
||||
|
||||
def test_query_builder_in_logical_group(self):
|
||||
qb = QueryBuilder()
|
||||
with qb.in_logical_group():
|
||||
qb.append("UPDATE Users SET name = 'John'")
|
||||
result = str(qb).strip()
|
||||
lines = result.splitlines()
|
||||
assert lines[0] == "BEGIN"
|
||||
assert lines[1] == "UPDATE Users SET name = 'John'"
|
||||
assert lines[2] == "END"
|
||||
|
||||
|
||||
class TestSqlCommand:
|
||||
def test_sql_command_initial_query(self):
|
||||
cmd = SqlCommand("SELECT 1")
|
||||
assert str(cmd.query) == "SELECT 1"
|
||||
|
||||
def test_sql_command_add_parameter(self):
|
||||
cmd = SqlCommand("SELECT * FROM Test WHERE id = ?")
|
||||
cmd.add_parameter("42")
|
||||
assert cmd.parameters[0] == "42"
|
||||
|
||||
def test_sql_command_add_parameters(self):
|
||||
cmd = SqlCommand("SELECT * FROM Test WHERE id = ?")
|
||||
cmd.add_parameters(["42", "43"])
|
||||
assert cmd.parameters[0] == "42"
|
||||
assert cmd.parameters[1] == "43"
|
||||
|
||||
def test_parameter_limit(self):
|
||||
cmd = SqlCommand()
|
||||
cmd.add_parameters(["42"] * 2100)
|
||||
with raises(VectorStoreOperationException):
|
||||
cmd.add_parameter("43")
|
||||
with raises(VectorStoreOperationException):
|
||||
cmd.add_parameters(["43", "44"])
|
||||
|
||||
|
||||
class TestQueryBuildFunctions:
|
||||
def test_build_create_table_query(self):
|
||||
schema = "dbo"
|
||||
table = "Test"
|
||||
key_field = VectorStoreField("key", name="id", type="str")
|
||||
data_fields = [
|
||||
VectorStoreField("data", name="name", type="str"),
|
||||
VectorStoreField("data", name="age", type="int"),
|
||||
]
|
||||
vector_fields = [
|
||||
VectorStoreField("vector", name="embedding", type="float", dimensions=1536),
|
||||
]
|
||||
cmd = _build_create_table_query(schema, table, key_field, data_fields, vector_fields)
|
||||
assert not cmd.parameters
|
||||
cmd_str = str(cmd.query)
|
||||
assert (
|
||||
cmd_str
|
||||
== "BEGIN\nCREATE TABLE [dbo].[Test] \n ([id] nvarchar(255) NOT NULL,\n[name] nvarchar(max) NULL,\n[age] "
|
||||
"int NULL,\n[embedding] VECTOR(1536) NULL,\nPRIMARY KEY ([id]) \n) ;\nEND\n"
|
||||
)
|
||||
|
||||
def test_build_create_table_query_escapes_single_quote_in_object_id(self):
|
||||
key_field = VectorStoreField("key", name="id", type="str")
|
||||
cmd = _build_create_table_query("dbo", "test'table", key_field, [], [], if_not_exists=True)
|
||||
cmd_str = str(cmd.query)
|
||||
# Single quote must be escaped inside the OBJECT_ID N'...' string literal
|
||||
assert "OBJECT_ID(N'[dbo].[test''table]'" in cmd_str
|
||||
|
||||
def test_build_create_table_query_uses_storage_name_for_primary_key(self):
|
||||
key_field = VectorStoreField("key", name="id", type="str", storage_name="pk_id")
|
||||
cmd = _build_create_table_query("dbo", "Test", key_field, [], [])
|
||||
cmd_str = str(cmd.query)
|
||||
assert "[pk_id] nvarchar" in cmd_str
|
||||
assert "PRIMARY KEY ([pk_id])" in cmd_str
|
||||
|
||||
def test_build_merge_query_output_uses_storage_name(self):
|
||||
key_field = VectorStoreField("key", name="id", type="str", storage_name="pk_id")
|
||||
records = [{"pk_id": "1"}]
|
||||
cmd = _build_merge_query("dbo", "Test", key_field, [], [], records)
|
||||
cmd_str = str(cmd.query)
|
||||
assert "OUTPUT inserted.[pk_id] INTO @UpsertedKeys" in cmd_str
|
||||
|
||||
def test_delete_table_query(self):
|
||||
schema = "dbo"
|
||||
table = "Test"
|
||||
cmd = _build_delete_table_query(schema, table)
|
||||
assert str(cmd.query) == f"DROP TABLE IF EXISTS [{schema}].[{table}] ;"
|
||||
|
||||
@mark.parametrize("schema", ["dbo", None])
|
||||
def test_build_select_table_names_query(self, schema):
|
||||
cmd = _build_select_table_names_query(schema)
|
||||
if schema:
|
||||
assert cmd.parameters == [schema]
|
||||
assert str(cmd) == (
|
||||
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES "
|
||||
"WHERE TABLE_TYPE = 'BASE TABLE' "
|
||||
"AND (@schema is NULL or TABLE_SCHEMA = ?);"
|
||||
)
|
||||
else:
|
||||
assert str(cmd) == "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE';"
|
||||
|
||||
def test_build_merge_query(self):
|
||||
schema = "dbo"
|
||||
table = "Test"
|
||||
key_field = VectorStoreField("key", name="id", type="str")
|
||||
data_fields = [
|
||||
VectorStoreField("data", name="name", type="str"),
|
||||
VectorStoreField("data", name="age", type="int"),
|
||||
]
|
||||
vector_fields = [
|
||||
VectorStoreField("vector", name="embedding", type="float", dimensions=5),
|
||||
]
|
||||
records = [
|
||||
{
|
||||
"id": "test",
|
||||
"name": "name",
|
||||
"age": 50,
|
||||
"embedding": [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
}
|
||||
]
|
||||
cmd = _build_merge_query(schema, table, key_field, data_fields, vector_fields, records)
|
||||
assert cmd.parameters[0] == records[0]["id"]
|
||||
assert cmd.parameters[1] == records[0]["name"]
|
||||
assert cmd.parameters[2] == str(records[0]["age"])
|
||||
assert cmd.parameters[3] == json.dumps(records[0]["embedding"])
|
||||
str_cmd = str(cmd)
|
||||
assert str_cmd == (
|
||||
"DECLARE @UpsertedKeys TABLE (KeyColumn nvarchar(255));\n"
|
||||
"MERGE INTO [dbo].[Test] AS t\n"
|
||||
"USING ( VALUES (?, ?, ?, ?) ) AS s ([id], [name], [age], [embedding]) "
|
||||
"ON (t.[id] = s.[id]) \n"
|
||||
"WHEN MATCHED THEN\n"
|
||||
"UPDATE SET t.[name] = s.[name], t.[age] = s.[age], t.[embedding] = s.[embedding]\n"
|
||||
"WHEN NOT MATCHED THEN\n"
|
||||
"INSERT ([id], [name], [age], [embedding]) "
|
||||
"VALUES (s.[id], s.[name], s.[age], s.[embedding]) \n"
|
||||
"OUTPUT inserted.[id] INTO @UpsertedKeys (KeyColumn);\n"
|
||||
"SELECT KeyColumn FROM @UpsertedKeys;\n"
|
||||
)
|
||||
|
||||
def test_build_select_query(self):
|
||||
schema = "dbo"
|
||||
table = "Test"
|
||||
key_field = VectorStoreField("key", name="id", type="str")
|
||||
data_fields = [
|
||||
VectorStoreField("data", name="name", type="str"),
|
||||
VectorStoreField("data", name="age", type="int"),
|
||||
]
|
||||
vector_fields = [
|
||||
VectorStoreField("vector", name="embedding", type="float", dimensions=5),
|
||||
]
|
||||
keys = ["test"]
|
||||
cmd = _build_select_query(schema, table, key_field, data_fields, vector_fields, keys)
|
||||
assert cmd.parameters == ["test"]
|
||||
str_cmd = str(cmd)
|
||||
assert str_cmd == "SELECT\n[id], [name], [age], [embedding] FROM [dbo].[Test] \nWHERE [id] IN\n (?) ;"
|
||||
|
||||
def test_build_delete_query(self):
|
||||
schema = "dbo"
|
||||
table = "Test"
|
||||
key_field = VectorStoreField("key", name="id", type="str")
|
||||
keys = ["test"]
|
||||
cmd = _build_delete_query(schema, table, key_field, keys)
|
||||
str_cmd = str(cmd)
|
||||
assert cmd.parameters[0] == "test"
|
||||
assert str_cmd == "DELETE FROM [dbo].[Test] WHERE [id] IN (?) ;"
|
||||
|
||||
def test_build_search_query(self):
|
||||
schema = "dbo"
|
||||
table = "Test"
|
||||
key_field = VectorStoreField("key", name="id", type="str")
|
||||
data_fields = [
|
||||
VectorStoreField("data", name="name", type="str"),
|
||||
VectorStoreField("data", name="age", type="int"),
|
||||
]
|
||||
vector_fields = [
|
||||
VectorStoreField(
|
||||
"vector",
|
||||
name="embedding",
|
||||
type="float",
|
||||
dimensions=5,
|
||||
distance_function=DistanceFunction.COSINE_DISTANCE,
|
||||
),
|
||||
]
|
||||
vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||
options = VectorSearchOptions(
|
||||
vector_property_name="embedding",
|
||||
)
|
||||
|
||||
cmd = _build_search_query(schema, table, key_field, data_fields, vector_fields, vector, options)
|
||||
assert cmd.parameters[0] == json.dumps(vector)
|
||||
str_cmd = str(cmd)
|
||||
assert (
|
||||
str_cmd == "SELECT [id], [name], [age], VECTOR_DISTANCE('cosine', [embedding], CAST(? AS VECTOR(5))) as "
|
||||
"_vector_distance_value\n FROM [dbo].[Test] \nORDER BY "
|
||||
"_vector_distance_value ASC\nOFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY;"
|
||||
)
|
||||
|
||||
|
||||
@fixture
|
||||
async def mock_connection(*args, **kwargs):
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"connection_string",
|
||||
[
|
||||
param(
|
||||
"Driver={ODBC Driver 18 for SQL Server};Server=localhost;Database=testdb;uid=testuserLongAsMax=yes;",
|
||||
id="with uid",
|
||||
),
|
||||
param(
|
||||
"Driver={ODBC Driver 18 for SQL Server};Server=localhost;Database=testdb;LongAsMax=yes;", id="credential"
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_get_mssql_connection(connection_string):
|
||||
mock_pyodbc = NonCallableMagicMock()
|
||||
sys.modules["pyodbc"] = mock_pyodbc
|
||||
|
||||
with patch("pyodbc.connect") as patched_connection:
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from semantic_kernel.connectors.sql_server import SqlSettings, _get_mssql_connection
|
||||
|
||||
token = MagicMock()
|
||||
token.token.return_value = "test_token"
|
||||
token.token.encode.return_value = b"test_token"
|
||||
credential = AsyncMock(spec=AsyncTokenCredential)
|
||||
credential.__aenter__.return_value = credential
|
||||
credential.get_token.return_value = token
|
||||
|
||||
settings = SqlSettings(connection_string=connection_string)
|
||||
with patch("semantic_kernel.connectors.sql_server.AsyncTokenCredential", return_value=credential):
|
||||
connection = await _get_mssql_connection(settings, credential=credential)
|
||||
assert connection is not None
|
||||
assert isinstance(connection, MagicMock)
|
||||
if "uid" in connection_string:
|
||||
assert patched_connection.call_args.kwargs["attrs_before"] is None
|
||||
else:
|
||||
assert patched_connection.call_args.kwargs["attrs_before"] == {
|
||||
1256: b"\n\x00\x00\x00test_token",
|
||||
}
|
||||
|
||||
|
||||
class TestSqlServerStore:
|
||||
async def test_create_store(self, sql_server_unit_test_env):
|
||||
store = SqlServerStore()
|
||||
assert store is not None
|
||||
assert store.settings is not None
|
||||
assert store.settings.connection_string is not None
|
||||
assert "LongAsMax=yes;" in store.settings.connection_string.get_secret_value()
|
||||
|
||||
with patch("semantic_kernel.connectors.sql_server._get_mssql_connection") as mock_get_connection:
|
||||
mock_get_connection.return_value = AsyncMock()
|
||||
await store.__aenter__()
|
||||
assert store.connection is not None
|
||||
|
||||
@mark.parametrize(
|
||||
"override_env_param_dict",
|
||||
[
|
||||
{
|
||||
"SQL_SERVER_CONNECTION_STRING": "Driver={ODBC Driver 18 for SQL Server};Server=localhost;Database=testdb;User Id=testuser;Password=example;LongAsMax=yes;" # noqa: E501
|
||||
}
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
def test_create_store_with_long_as_max(self, sql_server_unit_test_env):
|
||||
store = SqlServerStore()
|
||||
assert store is not None
|
||||
assert store.settings is not None
|
||||
assert store.settings.connection_string is not None
|
||||
|
||||
@mark.parametrize("exclude_list", ["SQL_SERVER_CONNECTION_STRING"], indirect=True)
|
||||
def test_create_without_connection_string(self, sql_server_unit_test_env):
|
||||
with raises(VectorStoreInitializationException):
|
||||
SqlServerStore(env_file_path="test.env")
|
||||
|
||||
def test_get_collection(self, sql_server_unit_test_env, definition):
|
||||
store = SqlServerStore()
|
||||
collection = store.get_collection(collection_name="test", record_type=dict, definition=definition)
|
||||
assert collection is not None
|
||||
|
||||
async def test_list_collection_names(self, sql_server_unit_test_env, mock_connection):
|
||||
async with SqlServerStore(connection=mock_connection) as store:
|
||||
mock_connection.cursor.return_value.__enter__.return_value.fetchall.return_value = [
|
||||
["Test1"],
|
||||
["Test2"],
|
||||
]
|
||||
collection_names = await store.list_collection_names()
|
||||
assert collection_names == ["Test1", "Test2"]
|
||||
|
||||
async def test_no_connection(self, sql_server_unit_test_env):
|
||||
store = SqlServerStore()
|
||||
with raises(VectorStoreOperationException):
|
||||
await store.list_collection_names()
|
||||
|
||||
|
||||
class TestSqlServerCollection:
|
||||
@mark.parametrize("exclude_list", ["SQL_SERVER_CONNECTION_STRING"], indirect=True)
|
||||
def test_create_without_connection_string(self, sql_server_unit_test_env, definition):
|
||||
with raises(VectorStoreInitializationException):
|
||||
SqlServerCollection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
async def test_create(self, sql_server_unit_test_env, definition):
|
||||
collection = SqlServerCollection(collection_name="test", record_type=dict, definition=definition)
|
||||
assert collection is not None
|
||||
assert collection.collection_name == "test"
|
||||
assert collection.settings is not None
|
||||
assert collection.settings.connection_string is not None
|
||||
|
||||
with patch("semantic_kernel.connectors.sql_server._get_mssql_connection") as mock_get_connection:
|
||||
mock_get_connection.return_value = AsyncMock()
|
||||
await collection.__aenter__()
|
||||
assert collection.connection is not None
|
||||
|
||||
async def test_upsert(
|
||||
self,
|
||||
sql_server_unit_test_env,
|
||||
mock_connection,
|
||||
definition,
|
||||
):
|
||||
collection = SqlServerCollection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
connection=mock_connection,
|
||||
)
|
||||
record = {"id": "1", "content": "test", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]}
|
||||
mock_connection.cursor.return_value.__enter__.return_value.nextset.side_effect = [True, False]
|
||||
mock_connection.cursor.return_value.__enter__.return_value.fetchall.return_value = [
|
||||
["1"],
|
||||
]
|
||||
await collection.upsert(record)
|
||||
mock_connection.cursor.return_value.__enter__.return_value.execute.assert_called_with(
|
||||
(
|
||||
"DECLARE @UpsertedKeys TABLE (KeyColumn nvarchar(255));\n"
|
||||
"MERGE INTO [dbo].[test] AS t\n"
|
||||
"USING ( VALUES (?, ?, ?) ) AS s ([id], [content], [vector]) "
|
||||
"ON (t.[id] = s.[id]) \n"
|
||||
"WHEN MATCHED THEN\n"
|
||||
"UPDATE SET t.[content] = s.[content], t.[vector] = s.[vector]\n"
|
||||
"WHEN NOT MATCHED THEN\n"
|
||||
"INSERT ([id], [content], [vector]) "
|
||||
"VALUES (s.[id], s.[content], s.[vector]) \n"
|
||||
"OUTPUT inserted.[id] INTO @UpsertedKeys (KeyColumn);\n"
|
||||
"SELECT KeyColumn FROM @UpsertedKeys;\n"
|
||||
),
|
||||
("1", "test", json.dumps([0.1, 0.2, 0.3, 0.4, 0.5])),
|
||||
)
|
||||
|
||||
async def test_get(
|
||||
self,
|
||||
sql_server_unit_test_env,
|
||||
mock_connection,
|
||||
definition,
|
||||
):
|
||||
class MockRow(NamedTuple):
|
||||
id: str
|
||||
content: str
|
||||
vector: str
|
||||
|
||||
mock_cursor = MagicMock()
|
||||
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
|
||||
collection = SqlServerCollection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
connection=mock_connection,
|
||||
)
|
||||
key = "1"
|
||||
|
||||
row = MockRow("1", "test", "[0.1, 0.2, 0.3, 0.4, 0.5]")
|
||||
mock_cursor.description = [["id"], ["content"], ["vector"]]
|
||||
|
||||
mock_cursor.__iter__.return_value = [row]
|
||||
record = await collection.get(key, include_vectors=True)
|
||||
mock_cursor.execute.assert_called_with(
|
||||
"SELECT\n[id], [content], [vector] FROM [dbo].[test] \nWHERE [id] IN\n (?) ;", ("1",)
|
||||
)
|
||||
assert record["id"] == "1"
|
||||
assert record["content"] == "test"
|
||||
assert record["vector"] == [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||
|
||||
async def test_delete(
|
||||
self,
|
||||
sql_server_unit_test_env,
|
||||
mock_connection,
|
||||
definition,
|
||||
):
|
||||
collection = SqlServerCollection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
connection=mock_connection,
|
||||
)
|
||||
key = "1"
|
||||
await collection.delete(key)
|
||||
mock_connection.cursor.return_value.__enter__.return_value.execute.assert_called_with(
|
||||
"DELETE FROM [dbo].[test] WHERE [id] IN (?) ;", ("1",)
|
||||
)
|
||||
|
||||
async def test_search(
|
||||
self,
|
||||
sql_server_unit_test_env,
|
||||
mock_connection,
|
||||
definition,
|
||||
):
|
||||
mock_cursor = MagicMock()
|
||||
mock_connection.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
for field in definition.vector_fields:
|
||||
field.distance_function = DistanceFunction.COSINE_DISTANCE
|
||||
collection = SqlServerCollection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
connection=mock_connection,
|
||||
)
|
||||
vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||
|
||||
@dataclass
|
||||
class MockRow:
|
||||
id: str
|
||||
content: str
|
||||
_vector_distance_value: float
|
||||
|
||||
row = MockRow("1", "test", 0.1)
|
||||
mock_cursor.description = [["id"], ["content"], ["_vector_distance_value"]]
|
||||
|
||||
mock_cursor.__iter__.return_value = [row]
|
||||
search_result = await collection.search(
|
||||
vector=vector,
|
||||
vector_property_name="vector",
|
||||
filter=lambda x: x.content == "test",
|
||||
)
|
||||
async for record in search_result.results:
|
||||
assert record.record["id"] == "1"
|
||||
assert record.record["content"] == "test"
|
||||
assert record.score == 0.1
|
||||
mock_cursor.execute.assert_called_with(
|
||||
(
|
||||
"SELECT [id], [content], VECTOR_DISTANCE('cosine', [vector], CAST(? AS VECTOR(5))) as "
|
||||
"_vector_distance_value\n FROM [dbo].[test] \n WHERE [content] = ? \nORDER BY _vector_distance_value "
|
||||
"ASC\nOFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY;"
|
||||
),
|
||||
(json.dumps(vector), "test"),
|
||||
)
|
||||
|
||||
async def test_ensure_collection_exists(
|
||||
self,
|
||||
sql_server_unit_test_env,
|
||||
mock_connection,
|
||||
definition,
|
||||
):
|
||||
for field in definition.vector_fields:
|
||||
field.index_kind = IndexKind.FLAT
|
||||
collection = SqlServerCollection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
connection=mock_connection,
|
||||
)
|
||||
await collection.ensure_collection_exists()
|
||||
mock_connection.cursor.return_value.__enter__.return_value.execute.assert_called_with(
|
||||
(
|
||||
"IF OBJECT_ID(N'[dbo].[test]', N'U') IS NULL\n"
|
||||
"BEGIN\nCREATE TABLE [dbo].[test] \n ([id] nvarchar"
|
||||
"(255) NOT NULL,\n[content] nvarchar(max) NULL,\n[vector] VECTOR(5) NULL,\n"
|
||||
"PRIMARY KEY ([id]) \n) ;"
|
||||
"\nEND\n"
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
async def test_ensure_collection_deleted(
|
||||
self,
|
||||
sql_server_unit_test_env,
|
||||
mock_connection,
|
||||
definition,
|
||||
):
|
||||
collection = SqlServerCollection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
connection=mock_connection,
|
||||
)
|
||||
await collection.ensure_collection_deleted()
|
||||
mock_connection.cursor.return_value.__enter__.return_value.execute.assert_called_with(
|
||||
"DROP TABLE IF EXISTS [dbo].[test] ;", ()
|
||||
)
|
||||
|
||||
async def test_no_connection(self, sql_server_unit_test_env, definition):
|
||||
collection = SqlServerCollection(
|
||||
collection_name="test",
|
||||
record_type=dict,
|
||||
definition=definition,
|
||||
)
|
||||
with raises(VectorStoreOperationException):
|
||||
await collection.ensure_collection_exists()
|
||||
with raises(VectorStoreOperationException):
|
||||
await collection.ensure_collection_deleted()
|
||||
with raises(VectorStoreOperationException):
|
||||
await collection.collection_exists()
|
||||
with raises(VectorStoreOperationException):
|
||||
await collection.upsert({"id": "1", "content": "test", "vector": [0.1, 0.2, 0.3, 0.4, 0.5]})
|
||||
with raises(VectorStoreOperationException):
|
||||
await collection.get("1")
|
||||
with raises(VectorStoreOperationException):
|
||||
await collection.delete("1")
|
||||
@@ -0,0 +1,70 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from weaviate import WeaviateAsyncClient
|
||||
from weaviate.collections.collections.async_ import _CollectionsAsync
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collections_side_effects(request):
|
||||
"""Fixture that returns a dictionary of side effects for the mock async client methods."""
|
||||
return request.param if hasattr(request, "param") else {}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_async_client(collections_side_effects) -> AsyncMock:
|
||||
"""Fixture to create a mock async client."""
|
||||
async_mock = AsyncMock(spec=WeaviateAsyncClient)
|
||||
async_mock.collections = AsyncMock(spec=_CollectionsAsync)
|
||||
async_mock.collections.create = AsyncMock()
|
||||
async_mock.collections.delete = AsyncMock()
|
||||
async_mock.collections.exists = AsyncMock()
|
||||
|
||||
if collections_side_effects:
|
||||
for method_name, exception in collections_side_effects.items():
|
||||
getattr(async_mock.collections, method_name).side_effect = exception
|
||||
|
||||
return async_mock
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def weaviate_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Weaviate unit tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"WEAVIATE_URL": "test-api-key",
|
||||
"WEAVIATE_API_KEY": "https://test-endpoint.com",
|
||||
"WEAVIATE_LOCAL_HOST": "localhost",
|
||||
"WEAVIATE_LOCAL_PORT": 8080,
|
||||
"WEAVIATE_LOCAL_GRPC_PORT": 8081,
|
||||
"WEAVIATE_USE_EMBED": True,
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def clear_weaviate_env(monkeypatch):
|
||||
"""Fixture to clear the environment variables for Weaviate unit tests."""
|
||||
monkeypatch.delenv("WEAVIATE_URL", raising=False)
|
||||
monkeypatch.delenv("WEAVIATE_API_KEY", raising=False)
|
||||
monkeypatch.delenv("WEAVIATE_LOCAL_HOST", raising=False)
|
||||
monkeypatch.delenv("WEAVIATE_LOCAL_PORT", raising=False)
|
||||
monkeypatch.delenv("WEAVIATE_LOCAL_GRPC_PORT", raising=False)
|
||||
monkeypatch.delenv("WEAVIATE_USE_EMBED", raising=False)
|
||||
@@ -0,0 +1,429 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import ANY, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from weaviate import WeaviateAsyncClient
|
||||
from weaviate.classes.config import Configure, DataType, Property
|
||||
from weaviate.collections.classes.config_vectorizers import VectorDistances
|
||||
from weaviate.collections.classes.data import DataObject
|
||||
|
||||
from semantic_kernel.connectors.weaviate import WeaviateCollection
|
||||
from semantic_kernel.exceptions import (
|
||||
ServiceInvalidExecutionSettingsError,
|
||||
VectorStoreInitializationException,
|
||||
VectorStoreOperationException,
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"semantic_kernel.connectors.weaviate.use_async_with_weaviate_cloud",
|
||||
return_value=AsyncMock(spec=WeaviateAsyncClient),
|
||||
)
|
||||
def test_weaviate_collection_init_with_weaviate_cloud(
|
||||
mock_use_weaviate_cloud,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateCollection object with Weaviate Cloud."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
url="https://test.cloud.weaviate.com/",
|
||||
api_key="test_api_key",
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
assert collection.collection_name == collection_name
|
||||
assert collection.async_client is not None
|
||||
mock_use_weaviate_cloud.assert_called_once_with(
|
||||
cluster_url="https://test.cloud.weaviate.com/",
|
||||
auth_credentials=ANY,
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"semantic_kernel.connectors.weaviate.use_async_with_local",
|
||||
return_value=AsyncMock(spec=WeaviateAsyncClient),
|
||||
)
|
||||
def test_weaviate_collection_init_with_local(
|
||||
mock_use_weaviate_local,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateCollection object with Weaviate local deployment."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
local_host="localhost",
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
assert collection.collection_name == collection_name
|
||||
assert collection.async_client is not None
|
||||
mock_use_weaviate_local.assert_called_once_with(host="localhost")
|
||||
|
||||
|
||||
@patch(
|
||||
"semantic_kernel.connectors.weaviate.use_async_with_embedded",
|
||||
return_value=AsyncMock(spec=WeaviateAsyncClient),
|
||||
)
|
||||
def test_weaviate_collection_init_with_embedded(
|
||||
mock_use_weaviate_embedded,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateCollection object with Weaviate embedded deployment."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
use_embed=True,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
assert collection.collection_name == collection_name
|
||||
assert collection.async_client is not None
|
||||
mock_use_weaviate_embedded.assert_called_once()
|
||||
|
||||
|
||||
def test_weaviate_collection_init_with_invalid_settings_more_than_one_backends(
|
||||
weaviate_unit_test_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateCollection object with multiple backend options enabled."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
|
||||
def test_weaviate_collection_init_with_invalid_settings_no_backends(
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateCollection object with no backend options enabled."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
|
||||
def test_weaviate_collection_init_with_custom_client(
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateCollection object with a custom client."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
async_client=AsyncMock(spec=WeaviateAsyncClient),
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
assert collection.collection_name == collection_name
|
||||
assert collection.async_client is not None
|
||||
|
||||
|
||||
@patch(
|
||||
"semantic_kernel.connectors.weaviate.use_async_with_local",
|
||||
side_effect=Exception,
|
||||
)
|
||||
def test_weaviate_collection_init_fail_to_create_client(
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateCollection object raises an error when failing to create a client."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
with pytest.raises(VectorStoreInitializationException):
|
||||
WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
local_host="localhost",
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"semantic_kernel.connectors.weaviate.use_async_with_weaviate_cloud",
|
||||
return_value=AsyncMock(spec=WeaviateAsyncClient),
|
||||
)
|
||||
def test_weaviate_collection_init_with_lower_case_collection_name(
|
||||
mock_use_weaviate_cloud,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test a collection name with lower case letters."""
|
||||
collection_name = "testCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
url="https://test.cloud.weaviate.com",
|
||||
api_key="test_api_key",
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
assert collection.collection_name[0].isupper()
|
||||
assert collection.async_client is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index_kind, distance_function", [("hnsw", "cosine_distance")])
|
||||
async def test_weaviate_collection_ensure_collection_exists(
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
mock_async_client,
|
||||
) -> None:
|
||||
"""Test the creation of a collection in Weaviate."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
async_client=mock_async_client,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
mock_async_client.collections.create.assert_called_once_with(
|
||||
name=collection_name,
|
||||
properties=[
|
||||
Property(
|
||||
name="content",
|
||||
data_type=DataType.TEXT,
|
||||
)
|
||||
],
|
||||
vector_index_config=None,
|
||||
vectorizer_config=[
|
||||
Configure.NamedVectors.none(
|
||||
name="vector",
|
||||
vector_index_config=Configure.VectorIndex.hnsw(distance_metric=VectorDistances.COSINE),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"collections_side_effects",
|
||||
[
|
||||
{"create": Exception},
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_weaviate_collection_ensure_collection_exists_fail(
|
||||
mock_async_client,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test failing to create a collection in Weaviate."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
async_client=mock_async_client,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
with pytest.raises(VectorStoreOperationException):
|
||||
await collection.ensure_collection_exists()
|
||||
|
||||
|
||||
async def test_weaviate_collection_ensure_collection_deleted(
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
mock_async_client,
|
||||
) -> None:
|
||||
"""Test the deletion of a collection in Weaviate."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
async_client=mock_async_client,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
await collection.ensure_collection_deleted()
|
||||
|
||||
mock_async_client.collections.delete.assert_called_once_with(collection_name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"collections_side_effects",
|
||||
[
|
||||
{"delete": Exception},
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_weaviate_collection_ensure_collection_deleted_fail(
|
||||
mock_async_client,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test failing to delete a collection in Weaviate."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
async_client=mock_async_client,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
with pytest.raises(VectorStoreOperationException):
|
||||
await collection.ensure_collection_deleted()
|
||||
|
||||
|
||||
async def test_weaviate_collection_collection_exist(
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
mock_async_client,
|
||||
) -> None:
|
||||
"""Test checking if a collection exists in Weaviate."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
async_client=mock_async_client,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
await collection.collection_exists()
|
||||
|
||||
mock_async_client.collections.exists.assert_called_once_with(collection_name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"collections_side_effects",
|
||||
[
|
||||
{"exists": Exception},
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_weaviate_collection_collection_exist_fail(
|
||||
mock_async_client,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test failing to check if a collection exists in Weaviate."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
async_client=mock_async_client,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
with pytest.raises(VectorStoreOperationException):
|
||||
await collection.collection_exists()
|
||||
|
||||
|
||||
async def test_weaviate_collection_serialize_data(
|
||||
mock_async_client,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
dataclass_vector_data_model,
|
||||
) -> None:
|
||||
"""Test upserting data into a collection in Weaviate."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
async_client=mock_async_client,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
with patch.object(collection, "_inner_upsert") as mock_inner_upsert:
|
||||
data = dataclass_vector_data_model()
|
||||
await collection.upsert(data)
|
||||
|
||||
mock_inner_upsert.assert_called_once_with([
|
||||
DataObject(
|
||||
properties={"content": "content1"},
|
||||
uuid=data.id,
|
||||
vector={"vector": data.vector},
|
||||
references=None,
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
async def test_weaviate_collection_deserialize_data(
|
||||
mock_async_client,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
dataclass_vector_data_model,
|
||||
) -> None:
|
||||
"""Test getting data from a collection in Weaviate."""
|
||||
collection_name = "TestCollection"
|
||||
|
||||
collection = WeaviateCollection(
|
||||
record_type=record_type,
|
||||
definition=definition,
|
||||
collection_name=collection_name,
|
||||
async_client=mock_async_client,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
data = dataclass_vector_data_model()
|
||||
weaviate_data_object = DataObject(
|
||||
properties={"content": "content1"},
|
||||
uuid=data.id,
|
||||
vector={"vector": data.vector or [1, 2, 3]},
|
||||
)
|
||||
|
||||
with patch.object(collection, "_inner_get", return_value=[weaviate_data_object]) as mock_inner_get:
|
||||
await collection.get(key=data.id)
|
||||
|
||||
mock_inner_get.assert_called_once_with([data.id], include_vectors=False, options=None)
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import ANY, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from weaviate import WeaviateAsyncClient
|
||||
|
||||
from semantic_kernel.connectors.weaviate import WeaviateStore
|
||||
from semantic_kernel.exceptions import ServiceInvalidExecutionSettingsError, VectorStoreInitializationException
|
||||
|
||||
|
||||
@patch(
|
||||
"semantic_kernel.connectors.weaviate.use_async_with_weaviate_cloud",
|
||||
return_value=AsyncMock(spec=WeaviateAsyncClient),
|
||||
)
|
||||
def test_weaviate_store_init_with_weaviate_cloud(
|
||||
mock_use_weaviate_cloud,
|
||||
clear_weaviate_env,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateStore object with Weaviate Cloud."""
|
||||
store = WeaviateStore(
|
||||
url="https://test.cloud.weaviate.com/",
|
||||
api_key="test_api_key",
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
assert store.async_client is not None
|
||||
mock_use_weaviate_cloud.assert_called_once_with(
|
||||
cluster_url="https://test.cloud.weaviate.com/",
|
||||
auth_credentials=ANY,
|
||||
)
|
||||
|
||||
|
||||
@patch(
|
||||
"semantic_kernel.connectors.weaviate.use_async_with_local",
|
||||
return_value=AsyncMock(spec=WeaviateAsyncClient),
|
||||
)
|
||||
def test_weaviate_store_init_with_local(
|
||||
mock_use_weaviate_local,
|
||||
clear_weaviate_env,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateStore object with Weaviate local deployment."""
|
||||
store = WeaviateStore(
|
||||
local_host="localhost",
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
assert store.async_client is not None
|
||||
mock_use_weaviate_local.assert_called_once_with(host="localhost")
|
||||
|
||||
|
||||
@patch(
|
||||
"semantic_kernel.connectors.weaviate.use_async_with_embedded",
|
||||
return_value=AsyncMock(spec=WeaviateAsyncClient),
|
||||
)
|
||||
def test_weaviate_store_init_with_embedded(
|
||||
mock_use_weaviate_embedded,
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateStore object with Weaviate embedded deployment."""
|
||||
store = WeaviateStore(
|
||||
use_embed=True,
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
assert store.async_client is not None
|
||||
mock_use_weaviate_embedded.assert_called_once()
|
||||
|
||||
|
||||
def test_weaviate_store_init_with_invalid_settings_more_than_one_backends(
|
||||
weaviate_unit_test_env,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateStore object with multiple backend options enabled."""
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
WeaviateStore(
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
|
||||
def test_weaviate_store_init_with_invalid_settings_no_backends(
|
||||
clear_weaviate_env,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateStore object with no backend options enabled."""
|
||||
with pytest.raises(ServiceInvalidExecutionSettingsError):
|
||||
WeaviateStore(
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
|
||||
def test_weaviate_store_init_with_custom_client(
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateStore object with a custom client."""
|
||||
store = WeaviateStore(
|
||||
async_client=AsyncMock(spec=WeaviateAsyncClient),
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
|
||||
assert store.async_client is not None
|
||||
|
||||
|
||||
@patch(
|
||||
"semantic_kernel.connectors.weaviate.use_async_with_local",
|
||||
side_effect=Exception,
|
||||
)
|
||||
def test_weaviate_store_init_fail_to_create_client(
|
||||
clear_weaviate_env,
|
||||
record_type,
|
||||
definition,
|
||||
) -> None:
|
||||
"""Test the initialization of a WeaviateStore object raises an error when failing to create a client."""
|
||||
with pytest.raises(VectorStoreInitializationException):
|
||||
WeaviateStore(
|
||||
local_host="localhost",
|
||||
env_file_path="fake_env_file_path.env",
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"openapi": "3.0.1",
|
||||
"info": {
|
||||
"title": "Semantic Kernel Open API Sample",
|
||||
"description": "A sample Open API schema with endpoints which have security requirements defined.",
|
||||
"version": "1.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://example.org"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/use_global_security": {
|
||||
"get": {
|
||||
"summary": "No security defined on operation",
|
||||
"description": "",
|
||||
"operationId": "NoSecurity",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "default"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Security defined on operation",
|
||||
"description": "",
|
||||
"operationId": "Security",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "default"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuthQuery": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"summary": "Security defined on operation with new scope",
|
||||
"description": "",
|
||||
"operationId": "SecurityAndScope",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "default"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuthQuery": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"ApiKeyAuthHeader": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-KEY"
|
||||
},
|
||||
"ApiKeyAuthQuery": {
|
||||
"type": "apiKey",
|
||||
"in": "query",
|
||||
"name": "apiKey"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuthHeader": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Simple HTTPBin API
|
||||
version: 1.0.0
|
||||
|
||||
servers:
|
||||
- url: https://httpbin.org
|
||||
|
||||
paths:
|
||||
/get:
|
||||
get:
|
||||
operationId: duplicateId
|
||||
summary: Simple GET request to httpbin.org
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response from httpbin
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/ip:
|
||||
get:
|
||||
operationId: duplicateId
|
||||
summary: Get client IP address from httpbin
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response with IP
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
@@ -0,0 +1,21 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Test API
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: http://example.com
|
||||
paths:
|
||||
/todos:
|
||||
get:
|
||||
summary: Get all todos
|
||||
operationId: getTodos
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
parameters:
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The authorization token
|
||||
@@ -0,0 +1,17 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Simple HTTPBin API
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://httpbin.org
|
||||
paths:
|
||||
/get:
|
||||
get:
|
||||
summary: Simple GET request to httpbin.org
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response from httpbin
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"openapi": "3.0.1",
|
||||
"info": {
|
||||
"title": "Semantic Kernel Open API Sample",
|
||||
"description": "A sample Open API schema with endpoints which have security requirements defined.",
|
||||
"version": "1.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://example.org"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/use_global_security": {
|
||||
"get": {
|
||||
"summary": "No security defined on operation",
|
||||
"description": "",
|
||||
"operationId": "NoSecurity",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "default"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Security defined on operation",
|
||||
"description": "",
|
||||
"operationId": "Security",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "default"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuthQuery": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"summary": "Security defined on operation with new scope",
|
||||
"description": "",
|
||||
"operationId": "SecurityAndScope",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "default"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuthQuery": ["new_scope"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"ApiKeyAuthHeader": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-KEY"
|
||||
},
|
||||
"ApiKeyAuthQuery": {
|
||||
"type": "apiKey",
|
||||
"in": "query",
|
||||
"name": "apiKey"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"openapi": "3.0.1",
|
||||
"info": {
|
||||
"title": "Semantic Kernel Open API Sample",
|
||||
"description": "A sample Open API schema with endpoints which have security requirements defined.",
|
||||
"version": "1.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://example.org"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/use_global_security": {
|
||||
"get": {
|
||||
"summary": "No security defined on operation",
|
||||
"description": "",
|
||||
"operationId": "NoSecurity",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "default"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Security defined on operation",
|
||||
"description": "",
|
||||
"operationId": "Security",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "default"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2Auth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"summary": "Security defined on operation with new scope",
|
||||
"description": "",
|
||||
"operationId": "SecurityAndScope",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "default"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2Auth": [ "new_scope" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"OAuth2Auth": {
|
||||
"type": "oauth2",
|
||||
"flows": {
|
||||
"authorizationCode": {
|
||||
"authorizationUrl": "https://login.windows.net/common/oauth2/authorize",
|
||||
"tokenUrl": "https://login.windows.net/common/oauth2/authorize",
|
||||
"scopes": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiKeyAuthHeader": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-KEY"
|
||||
},
|
||||
"ApiKeyAuthQuery": {
|
||||
"type": "apiKey",
|
||||
"in": "query",
|
||||
"name": "apiKey"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2Auth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Test API
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: http://example.com
|
||||
- url: https://example-two.com
|
||||
paths:
|
||||
/todos:
|
||||
get:
|
||||
summary: Get all todos
|
||||
operationId: getTodos
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
parameters:
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The authorization token
|
||||
post:
|
||||
summary: Add a new todo
|
||||
operationId: addTodo
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: The title of the todo
|
||||
example: Buy milk
|
||||
completed:
|
||||
type: boolean
|
||||
description: Whether the todo is completed or not
|
||||
example: false
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
parameters:
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The authorization token
|
||||
/todos/{id}:
|
||||
get:
|
||||
summary: Get a todo by ID
|
||||
operationId: getTodoById
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The authorization token
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
put:
|
||||
summary: Update a todo by ID
|
||||
operationId: updateTodoById
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The authorization token
|
||||
- name: completed
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
description: Whether the todo is completed or not
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: The title of the todo
|
||||
example: Buy milk
|
||||
completed:
|
||||
type: boolean
|
||||
description: Whether the todo is completed or not
|
||||
example: false
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
delete:
|
||||
summary: Delete a todo by ID
|
||||
operationId: deleteTodoById
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: Authorization
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The authorization token
|
||||
responses:
|
||||
'204':
|
||||
description: No Content
|
||||
@@ -0,0 +1,57 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Todo List API
|
||||
version: 1.0.0
|
||||
description: API for managing todo lists
|
||||
paths:
|
||||
/list:
|
||||
get:
|
||||
summary: Get todo list
|
||||
operationId: get_todo_list
|
||||
description: get todo list from specific group
|
||||
parameters:
|
||||
- name: listName
|
||||
in: query
|
||||
required: true
|
||||
description: todo list group name description
|
||||
schema:
|
||||
type: string
|
||||
description: todo list group name
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
task:
|
||||
type: string
|
||||
listName:
|
||||
type: string
|
||||
|
||||
/add:
|
||||
post:
|
||||
summary: Add a task to a list
|
||||
operationId: add_todo_list
|
||||
description: add todo to specific group
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- task
|
||||
properties:
|
||||
task:
|
||||
type: string
|
||||
description: task name
|
||||
listName:
|
||||
type: string
|
||||
description: task group name
|
||||
responses:
|
||||
"201":
|
||||
description: Task added successfully
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user