chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user