mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 02:19:06 +00:00
Python: Support structured outputs with Azure AI inference chat completion. (#12183)
### Motivation and Context The Azure AI inference SDK released support for structured outputs for chat completions (for models that support it). We had not brought this into SK. This PR adds functionality and the ability to run the structured output samples that exist today. <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description Adds functionality to handle structured outputs for the Azure AI Inference Chat Completion calls. - Fixes a bug within AzureAIInferenceChatCompletion where the wrong isinstance check type is used. This prevented adding tool calls during streaming invocation. Also properly add the `ai_model_id` from the connector during message construction during streaming invocation, - Adds unit tests for AzureAIInferenceChatCompletion structured outputs - Closes #11952 <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone 😄
This commit is contained in:
@@ -1,107 +1,95 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from samples.concepts.setup.chat_completion_services import Services, get_chat_completion_service_and_request_settings
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.connectors.ai.open_ai.services.azure_chat_completion import AzureChatCompletion
|
||||
from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion
|
||||
from semantic_kernel.contents import ChatHistory
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.connectors.ai import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents import ChatHistory, StreamingChatMessageContent
|
||||
|
||||
###################################################################
|
||||
# The following sample demonstrates how to create a chat #
|
||||
# completion call that assists users in solving math problems. #
|
||||
# The bot guides the user step-by-step through the solution #
|
||||
# process using a structured output format based on either a #
|
||||
# Pydantic model or a non-Pydantic model. #
|
||||
###################################################################
|
||||
"""
|
||||
The following sample demonstrates how to create a chat
|
||||
completion call that assists users in solving math problems.
|
||||
The bot guides the user step-by-step through the solution
|
||||
process using a structured output format based on either a
|
||||
Pydantic model or a non-Pydantic model.
|
||||
|
||||
|
||||
###################################################################
|
||||
# NOTE: If using Azure OpenAI the the following is required:
|
||||
# - access to gpt-4o-2024-08-06
|
||||
# - the 2024-08-01-preview API version
|
||||
# - if using a token instead of an API KEY, you must have the
|
||||
# `Cognitive Services OpenAI Contributor` role assigned to your
|
||||
# Azure AD user.
|
||||
# - flip the `use_azure_openai` flag to `True`
|
||||
###################################################################
|
||||
use_azure_openai = False
|
||||
NOTE: If using Azure OpenAI the the following is required:
|
||||
- access to gpt-4o-2024-08-06
|
||||
- the 2024-08-01-preview API version
|
||||
- if using a token instead of an API KEY, you must have the
|
||||
`Cognitive Services OpenAI Contributor` role assigned to your
|
||||
Azure AD user.
|
||||
- flip the `use_azure_openai` flag to `True`
|
||||
"""
|
||||
|
||||
system_message = """
|
||||
You are a helpful math tutor. Guide the user through the solution step by step.
|
||||
"""
|
||||
|
||||
"""
|
||||
Define the Pydantic model that represents the
|
||||
structured output from the OpenAI service. This model will be
|
||||
used to parse the structured output from the OpenAI service,
|
||||
and ensure that the model correctly outputs the schema based
|
||||
on the Pydantic model.
|
||||
"""
|
||||
|
||||
###################################################################
|
||||
# OPTION 1: Define the Pydantic model that represents the
|
||||
# structured output from the OpenAI service. This model will be
|
||||
# used to parse the structured output from the OpenAI service,
|
||||
# and ensure that the model correctly outputs the schema based
|
||||
# on the Pydantic model.
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel # noqa: E402
|
||||
# Note: The `extra=forbid` means to forbid extra fields during model initialization
|
||||
# It is required to ensure that the model is strict and does not
|
||||
# accept any extra fields that are not defined in the model.
|
||||
|
||||
|
||||
class Step(KernelBaseModel):
|
||||
class Step(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
explanation: str
|
||||
output: str
|
||||
|
||||
|
||||
class Reasoning(KernelBaseModel):
|
||||
class Reasoning(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
steps: list[Step]
|
||||
final_answer: str
|
||||
|
||||
|
||||
###################################################################
|
||||
|
||||
|
||||
# OPTION 2: Define a non-Pydantic model that should represent the
|
||||
# structured output from the OpenAI service. This model will be
|
||||
# converted to the proper JSON Schema and sent to the LLM.
|
||||
# Uncomment the follow lines and comment out the Pydantic model
|
||||
# above to use this option.
|
||||
# class Step:
|
||||
# explanation: str
|
||||
# output: str
|
||||
|
||||
|
||||
# class Reasoning:
|
||||
# steps: list[Step]
|
||||
# final_answer: str
|
||||
|
||||
|
||||
###################################################################
|
||||
|
||||
kernel = Kernel()
|
||||
|
||||
service_id = "structured-output"
|
||||
if use_azure_openai:
|
||||
chat_service = AzureChatCompletion(
|
||||
service_id=service_id,
|
||||
)
|
||||
else:
|
||||
chat_service = OpenAIChatCompletion(
|
||||
service_id=service_id,
|
||||
)
|
||||
kernel.add_service(chat_service)
|
||||
# You can select from the following chat completion services:
|
||||
# Note: the model must allow for structured outputs.
|
||||
# - Services.OPENAI
|
||||
# - Services.AZURE_OPENAI
|
||||
# - Services.AZURE_AI_INFERENCE
|
||||
# - Services.ANTHROPIC
|
||||
# - Services.BEDROCK
|
||||
# - Services.GOOGLE_AI
|
||||
# - Services.MISTRAL_AI
|
||||
# - Services.OLLAMA
|
||||
# - Services.ONNX
|
||||
# - Services.VERTEX_AI
|
||||
# - Services.DEEPSEEK
|
||||
# Please make sure you have configured your environment correctly for the selected chat completion service.
|
||||
chat_completion_service, request_settings = get_chat_completion_service_and_request_settings(Services.AZURE_OPENAI)
|
||||
kernel.add_service(chat_completion_service)
|
||||
|
||||
req_settings = kernel.get_prompt_execution_settings_from_service_id(service_id=service_id)
|
||||
req_settings.max_tokens = 2000
|
||||
req_settings.temperature = 0.7
|
||||
req_settings.top_p = 0.8
|
||||
req_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(filters={"excluded_plugins": ["chat"]})
|
||||
request_settings.max_tokens = 2000
|
||||
request_settings.temperature = 0.7
|
||||
request_settings.top_p = 0.8
|
||||
request_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(filters={"excluded_plugins": ["chat"]})
|
||||
|
||||
# NOTE: This is the key setting in this example that tells the OpenAI service
|
||||
# to return structured output based on the Pydantic model Reasoning.
|
||||
req_settings.response_format = Reasoning
|
||||
request_settings.response_format = Reasoning
|
||||
|
||||
|
||||
chat_function = kernel.add_function(
|
||||
prompt=system_message + """{{$chat_history}}""",
|
||||
function_name="chat",
|
||||
plugin_name="chat",
|
||||
prompt_execution_settings=req_settings,
|
||||
prompt_execution_settings=request_settings,
|
||||
)
|
||||
|
||||
history = ChatHistory()
|
||||
@@ -127,9 +115,23 @@ async def main():
|
||||
chat_function,
|
||||
chat_history=history,
|
||||
)
|
||||
reasoned_result = Reasoning.model_validate_json(result.value[0].content)
|
||||
print(f"Mosscap:> {reasoned_result}")
|
||||
history.add_assistant_message(str(result))
|
||||
reasoned_result = Reasoning.model_validate(json.loads(result.value[0].content))
|
||||
print(f"{reasoned_result.model_dump_json(indent=4)}")
|
||||
history.add_assistant_message(str(result))
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"explanation": "User requested the current weather condition in Paris, so I utilized the 'weather-get_weather_for_city' function to retrieve the data.",
|
||||
"output": "The weather in Paris is 60 degrees Fahrenheit and rainy."
|
||||
}
|
||||
],
|
||||
"final_answer": "The current weather in Paris is 60 degrees Fahrenheit and rainy."
|
||||
}
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+85
-83
@@ -1,39 +1,35 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from functools import reduce
|
||||
from typing import Annotated
|
||||
|
||||
from samples.concepts.setup.chat_completion_services import Services, get_chat_completion_service_and_request_settings
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.connectors.ai.open_ai.services.azure_chat_completion import AzureChatCompletion
|
||||
from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion
|
||||
from semantic_kernel.contents import ChatHistory
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.connectors.ai import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents import ChatHistory, FunctionResultContent, StreamingChatMessageContent
|
||||
from semantic_kernel.functions import kernel_function
|
||||
|
||||
###################################################################
|
||||
# The following sample demonstrates how to create a chat #
|
||||
# completion call that assists users in solving a question #
|
||||
# using a Semantic Kernel Plugin and function calling problems. #
|
||||
# The chat plugin guides the user step-by-step through the #
|
||||
# solution process using a structured output format based on #
|
||||
# either a Pydantic model or a non-Pydantic model #
|
||||
###################################################################
|
||||
"""
|
||||
The following sample demonstrates how to create a chat
|
||||
completion call that assists users in solving a question
|
||||
using a Semantic Kernel Plugin and function calling problems.
|
||||
The chat plugin guides the user step-by-step through the
|
||||
solution process using a structured output format based on
|
||||
a Pydantic model.
|
||||
|
||||
|
||||
###################################################################
|
||||
# NOTE: If using Azure OpenAI the the following is required:
|
||||
# - access to gpt-4o-2024-08-06
|
||||
# - the 2024-08-01-preview API version
|
||||
# - if using a token instead of an API KEY, you must have the
|
||||
# `Cognitive Services OpenAI Contributor` role assigned to your
|
||||
# Azure AD user.
|
||||
# - flip the `use_azure_openai` flag to `True`
|
||||
###################################################################
|
||||
use_azure_openai = True
|
||||
NOTE: If using Azure OpenAI the the following is required:
|
||||
- access to gpt-4o-2024-08-06
|
||||
- the 2024-08-01-preview API version
|
||||
- if using a token instead of an API KEY, you must have the
|
||||
`Cognitive Services OpenAI Contributor` role assigned to your
|
||||
Azure AD user.
|
||||
- flip the `use_azure_openai` flag to `True`
|
||||
"""
|
||||
|
||||
system_message = """
|
||||
You are a helpful math tutor. Guide the user through the solution step by step.
|
||||
You are a helpful assistant who provides answers to the user's questions in structured JSON format.
|
||||
"""
|
||||
|
||||
|
||||
@@ -60,84 +56,69 @@ class WeatherPlugin:
|
||||
return "31 and snowing"
|
||||
|
||||
|
||||
###################################################################
|
||||
# OPTION 1: Define the Pydantic model that represents the
|
||||
# structured output from the OpenAI service. This model will be
|
||||
# used to parse the structured output from the OpenAI service,
|
||||
# and ensure that the model correctly outputs the schema based
|
||||
# on the Pydantic model.
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel # noqa: E402
|
||||
"""
|
||||
Define the Pydantic model that represents the
|
||||
structured output from the OpenAI service. This model will be
|
||||
used to parse the structured output from the OpenAI service,
|
||||
and ensure that the model correctly outputs the schema based
|
||||
on the Pydantic model.
|
||||
"""
|
||||
from pydantic import BaseModel, ConfigDict # noqa: E402
|
||||
|
||||
# Note: The `extra=forbid` means to forbid extra fields during model initialization
|
||||
# It is required to ensure that the model is strict and does not
|
||||
# accept any extra fields that are not defined in the model.
|
||||
|
||||
|
||||
class Step(KernelBaseModel):
|
||||
class Step(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
explanation: str
|
||||
output: str
|
||||
|
||||
|
||||
class Reasoning(KernelBaseModel):
|
||||
class Reasoning(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
steps: list[Step]
|
||||
final_answer: str
|
||||
|
||||
|
||||
###################################################################
|
||||
|
||||
|
||||
# OPTION 2: Define a non-Pydantic model that should represent the
|
||||
# structured output from the OpenAI service. This model will be
|
||||
# converted to the proper JSON Schema and sent to the LLM.
|
||||
# Uncomment the follow lines and comment out the Pydantic model
|
||||
# above to use this option.
|
||||
# class Step:
|
||||
# explanation: str
|
||||
# output: str
|
||||
|
||||
|
||||
# class Reasoning:
|
||||
# steps: list[Step]
|
||||
# final_answer: str
|
||||
|
||||
|
||||
###################################################################
|
||||
|
||||
kernel = Kernel()
|
||||
|
||||
service_id = "structured-output"
|
||||
if use_azure_openai:
|
||||
chat_service = AzureChatCompletion(
|
||||
service_id=service_id,
|
||||
)
|
||||
else:
|
||||
chat_service = OpenAIChatCompletion(
|
||||
service_id=service_id,
|
||||
)
|
||||
kernel.add_service(chat_service)
|
||||
# You can select from the following chat completion services:
|
||||
# Note: the model must allow for structured outputs.
|
||||
# - Services.OPENAI
|
||||
# - Services.AZURE_OPENAI
|
||||
# - Services.AZURE_AI_INFERENCE
|
||||
# - Services.ANTHROPIC
|
||||
# - Services.BEDROCK
|
||||
# - Services.GOOGLE_AI
|
||||
# - Services.MISTRAL_AI
|
||||
# - Services.OLLAMA
|
||||
# - Services.ONNX
|
||||
# - Services.VERTEX_AI
|
||||
# - Services.DEEPSEEK
|
||||
# Please make sure you have configured your environment correctly for the selected chat completion service.
|
||||
chat_completion_service, request_settings = get_chat_completion_service_and_request_settings(Services.AZURE_OPENAI)
|
||||
kernel.add_service(chat_completion_service)
|
||||
|
||||
kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
|
||||
|
||||
req_settings = kernel.get_prompt_execution_settings_from_service_id(service_id=service_id)
|
||||
req_settings.max_tokens = 2000
|
||||
req_settings.temperature = 0.7
|
||||
req_settings.top_p = 0.8
|
||||
req_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(filters={"excluded_plugins": ["chat"]})
|
||||
|
||||
# NOTE: This is the key setting in this example that tells the OpenAI service
|
||||
# to return structured output based on the Pydantic model Reasoning.
|
||||
req_settings.response_format = Reasoning
|
||||
|
||||
request_settings.response_format = Reasoning
|
||||
request_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(filters={"excluded_plugins": ["chat"]})
|
||||
|
||||
chat_function = kernel.add_function(
|
||||
prompt=system_message + """{{$chat_history}}""",
|
||||
function_name="chat",
|
||||
plugin_name="chat",
|
||||
prompt_execution_settings=req_settings,
|
||||
prompt_execution_settings=request_settings,
|
||||
)
|
||||
|
||||
history = ChatHistory()
|
||||
history.add_user_message("Using the available plugin, what is the weather in Paris?")
|
||||
history.add_user_message("What is the weather in Paris?")
|
||||
|
||||
|
||||
async def main():
|
||||
stream = True
|
||||
stream = False
|
||||
if stream:
|
||||
answer = kernel.invoke_stream(
|
||||
chat_function,
|
||||
@@ -146,17 +127,38 @@ async def main():
|
||||
print("Mosscap:> ", end="")
|
||||
result_content: list[StreamingChatMessageContent] = []
|
||||
async for message in answer:
|
||||
result_content.append(message[0])
|
||||
print(str(message[0]), end="", flush=True)
|
||||
if not any(isinstance(item, FunctionResultContent | FunctionResultContent) for item in message[0].items):
|
||||
print(str(message[0]), end="", flush=True)
|
||||
result_content.append(message[0])
|
||||
print()
|
||||
if result_content:
|
||||
result = "".join([str(content) for content in result_content])
|
||||
full_response: StreamingChatMessageContent = reduce(lambda x, y: x + y, result_content)
|
||||
reasoned_result = Reasoning.model_validate(json.loads(full_response.content))
|
||||
print("Result formatted in JSON:")
|
||||
print(f"{reasoned_result.model_dump_json(indent=4)}")
|
||||
history.add_assistant_message(str(full_response))
|
||||
else:
|
||||
result = await kernel.invoke(
|
||||
chat_function,
|
||||
chat_history=history,
|
||||
)
|
||||
print(f"Mosscap:> {result}")
|
||||
history.add_assistant_message(str(result))
|
||||
reasoned_result = Reasoning.model_validate(json.loads(result.value[0].content))
|
||||
print(f"{reasoned_result.model_dump_json(indent=4)}")
|
||||
history.add_assistant_message(str(result))
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"explanation": "User requested the current weather condition in Paris, so I utilized the 'weather-get_weather_for_city' function to retrieve the data.",
|
||||
"output": "The weather in Paris is 60 degrees Fahrenheit and rainy."
|
||||
}
|
||||
],
|
||||
"final_answer": "The current weather in Paris is 60 degrees Fahrenheit and rainy."
|
||||
}
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+41
-1
@@ -2,9 +2,10 @@
|
||||
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.exceptions.service_exceptions import ServiceInvalidExecutionSettingsError
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@@ -30,6 +31,12 @@ class AzureAIInferencePromptExecutionSettings(PromptExecutionSettings):
|
||||
class AzureAIInferenceChatPromptExecutionSettings(AzureAIInferencePromptExecutionSettings):
|
||||
"""Azure AI Inference Chat Prompt Execution Settings."""
|
||||
|
||||
response_format: (
|
||||
dict[Literal["type"], Literal["text", "json_object"]] | dict[str, Any] | type[BaseModel] | type | None
|
||||
) = None
|
||||
structured_json_response: Annotated[
|
||||
bool, Field(description="Do not set this manually. It is set by the service.")
|
||||
] = False
|
||||
tools: Annotated[
|
||||
list[dict[str, Any]] | None,
|
||||
Field(
|
||||
@@ -45,6 +52,39 @@ class AzureAIInferenceChatPromptExecutionSettings(AzureAIInferencePromptExecutio
|
||||
),
|
||||
] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
def validate_response_format_and_set_flag(cls, values: Any) -> Any:
|
||||
"""Validate the response_format and set structured_json_response accordingly."""
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
response_format = values.get("response_format", None)
|
||||
|
||||
if response_format is None:
|
||||
return values
|
||||
|
||||
if isinstance(response_format, dict):
|
||||
if response_format.get("type") == "json_object":
|
||||
return values
|
||||
if response_format.get("type") == "json_schema":
|
||||
json_schema = response_format.get("json_schema")
|
||||
if isinstance(json_schema, dict):
|
||||
values["structured_json_response"] = True
|
||||
return values
|
||||
raise ServiceInvalidExecutionSettingsError(
|
||||
"If response_format has type 'json_schema', 'json_schema' must be a valid dictionary."
|
||||
)
|
||||
if isinstance(response_format, type):
|
||||
if issubclass(response_format, BaseModel):
|
||||
values["structured_json_response"] = True
|
||||
else:
|
||||
values["structured_json_response"] = True
|
||||
else:
|
||||
raise ServiceInvalidExecutionSettingsError(
|
||||
"response_format must be a dictionary, a subclass of BaseModel, a Python class/type, or None"
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
@experimental
|
||||
class AzureAIInferenceEmbeddingPromptExecutionSettings(PromptExecutionSettings):
|
||||
|
||||
+49
-9
@@ -5,11 +5,6 @@ import sys
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
from azure.ai.inference.aio import ChatCompletionsClient
|
||||
from azure.ai.inference.models import (
|
||||
AsyncStreamingChatCompletions,
|
||||
@@ -17,9 +12,12 @@ from azure.ai.inference.models import (
|
||||
ChatCompletions,
|
||||
ChatCompletionsToolCall,
|
||||
ChatRequestMessage,
|
||||
JsonSchemaFormat,
|
||||
StreamingChatChoiceUpdate,
|
||||
StreamingChatCompletionsUpdate,
|
||||
StreamingChatResponseToolCallUpdate,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference import AzureAIInferenceChatPromptExecutionSettings
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference.services.azure_ai_inference_base import (
|
||||
@@ -42,12 +40,18 @@ 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 ServiceInvalidExecutionSettingsError
|
||||
from semantic_kernel.schema.kernel_json_schema_builder import KernelJsonSchemaBuilder
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.connectors.ai.function_call_choice_configuration import FunctionCallChoiceConfiguration
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -134,12 +138,14 @@ class AzureAIInferenceChatCompletion(ChatCompletionClientBase, AzureAIInferenceB
|
||||
|
||||
assert isinstance(self.client, ChatCompletionsClient) # nosec
|
||||
with AzureAIInferenceTracing():
|
||||
settings_dict = settings.prepare_settings_dict()
|
||||
self._handle_structured_output(settings, settings_dict)
|
||||
response: ChatCompletions = await self.client.complete(
|
||||
messages=self._prepare_chat_history_for_request(chat_history),
|
||||
# The model id will be ignored by the service if the endpoint serves only one model (i.e. MaaS)
|
||||
model=self.ai_model_id,
|
||||
model_extras=settings.extra_parameters,
|
||||
**settings.prepare_settings_dict(),
|
||||
**settings_dict,
|
||||
)
|
||||
response_metadata = self._get_metadata_from_response(response)
|
||||
|
||||
@@ -158,13 +164,15 @@ class AzureAIInferenceChatCompletion(ChatCompletionClientBase, AzureAIInferenceB
|
||||
|
||||
assert isinstance(self.client, ChatCompletionsClient) # nosec
|
||||
with AzureAIInferenceTracing():
|
||||
settings_dict = settings.prepare_settings_dict()
|
||||
self._handle_structured_output(settings, settings_dict)
|
||||
response: AsyncStreamingChatCompletions = await self.client.complete(
|
||||
stream=True,
|
||||
# The model id will be ignored by the service if the endpoint serves only one model (i.e. MaaS)
|
||||
model=self.ai_model_id,
|
||||
messages=self._prepare_chat_history_for_request(chat_history),
|
||||
model_extras=settings.extra_parameters,
|
||||
**settings.prepare_settings_dict(),
|
||||
**settings_dict,
|
||||
)
|
||||
|
||||
async for chunk in response:
|
||||
@@ -223,6 +231,37 @@ class AzureAIInferenceChatCompletion(ChatCompletionClientBase, AzureAIInferenceB
|
||||
|
||||
return chat_request_messages
|
||||
|
||||
def _handle_structured_output(
|
||||
self, request_settings: AzureAIInferenceChatPromptExecutionSettings, settings: dict[str, Any]
|
||||
) -> None:
|
||||
response_format = getattr(request_settings, "response_format", None)
|
||||
if getattr(request_settings, "structured_json_response", False) and response_format:
|
||||
# Case 1: response_format is a Pydantic BaseModel type
|
||||
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
|
||||
schema = response_format.model_json_schema()
|
||||
settings["response_format"] = JsonSchemaFormat(
|
||||
name=response_format.__name__,
|
||||
schema=schema,
|
||||
description=f"Schema for {response_format.__name__}",
|
||||
strict=True,
|
||||
)
|
||||
# Case 2: response_format is a type but not a subclass of BaseModel
|
||||
elif isinstance(response_format, type):
|
||||
generated_schema = KernelJsonSchemaBuilder.build(parameter_type=response_format, structured_output=True)
|
||||
assert generated_schema is not None # nosec
|
||||
settings["response_format"] = JsonSchemaFormat(
|
||||
name=response_format.__name__,
|
||||
schema=generated_schema,
|
||||
description=f"Schema for {response_format.__name__}",
|
||||
strict=True,
|
||||
)
|
||||
# Case 3: response_format is already a JsonSchemaFormat instance, pass it
|
||||
elif isinstance(response_format, JsonSchemaFormat):
|
||||
settings["response_format"] = response_format
|
||||
# Case 4: response_format is a dictionary (legacy), create JsonSchemaFormat from dict
|
||||
elif isinstance(response_format, dict):
|
||||
settings["response_format"] = JsonSchemaFormat(**response_format)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Non-streaming
|
||||
@@ -301,7 +340,7 @@ class AzureAIInferenceChatCompletion(ChatCompletionClientBase, AzureAIInferenceB
|
||||
)
|
||||
if choice.delta.tool_calls:
|
||||
for tool_call in choice.delta.tool_calls:
|
||||
if isinstance(tool_call, ChatCompletionsToolCall):
|
||||
if isinstance(tool_call, StreamingChatResponseToolCallUpdate):
|
||||
items.append(
|
||||
FunctionCallContent(
|
||||
id=tool_call.id,
|
||||
@@ -312,13 +351,14 @@ class AzureAIInferenceChatCompletion(ChatCompletionClientBase, AzureAIInferenceB
|
||||
)
|
||||
|
||||
return StreamingChatMessageContent(
|
||||
role=AuthorRole(choice.delta.role) if choice.delta.role else AuthorRole.ASSISTANT,
|
||||
role=(AuthorRole(choice.delta.role) if choice.delta and choice.delta.role else AuthorRole.ASSISTANT),
|
||||
items=items,
|
||||
choice_index=choice.index,
|
||||
inner_content=chunk,
|
||||
finish_reason=FinishReason(choice.finish_reason) if choice.finish_reason else None,
|
||||
metadata=metadata,
|
||||
function_invoke_attempt=function_invoke_attempt,
|
||||
ai_model_id=self.ai_model_id,
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -15,6 +15,7 @@ from azure.ai.inference.models import (
|
||||
FunctionCall,
|
||||
StreamingChatChoiceUpdate,
|
||||
StreamingChatCompletionsUpdate,
|
||||
StreamingChatResponseToolCallUpdate,
|
||||
)
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
|
||||
@@ -249,7 +250,7 @@ def mock_azure_ai_inference_streaming_chat_completion_response_with_tool_call(mo
|
||||
delta=ChatResponseMessage(
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
ChatCompletionsToolCall(
|
||||
StreamingChatResponseToolCallUpdate(
|
||||
id="test_id",
|
||||
function=FunctionCall(
|
||||
name="getLightStatus",
|
||||
|
||||
+98
-1
@@ -4,8 +4,9 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.inference.aio import ChatCompletionsClient
|
||||
from azure.ai.inference.models import UserMessage
|
||||
from azure.ai.inference.models import JsonSchemaFormat, UserMessage
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from semantic_kernel.connectors.ai.azure_ai_inference import (
|
||||
AzureAIInferenceChatCompletion,
|
||||
@@ -335,6 +336,55 @@ async def test_azure_ai_inference_chat_completion_with_function_choice_behavior_
|
||||
assert responses[0].content == "Hello"
|
||||
|
||||
|
||||
class MockResponseModel(BaseModel):
|
||||
a: int = Field(..., description="The a field")
|
||||
b: str = Field(..., description="The b field")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_response_format_json_schema(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_chat_completion_response,
|
||||
):
|
||||
chat_history.add_user_message("Return an object with fields a and b.")
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
response_format=MockResponseModel,
|
||||
structured_json_response=True,
|
||||
)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_chat_completion_response
|
||||
|
||||
_ = await azure_ai_inference_service.get_chat_message_contents(chat_history=chat_history, settings=settings)
|
||||
|
||||
assert mock_complete.call_count == 1
|
||||
kwargs = mock_complete.call_args.kwargs
|
||||
|
||||
assert "response_format" in kwargs
|
||||
response_format = kwargs["response_format"]
|
||||
assert isinstance(response_format, JsonSchemaFormat)
|
||||
assert response_format.name == "MockResponseModel"
|
||||
assert response_format.strict is True
|
||||
|
||||
schema = response_format.schema
|
||||
assert schema["title"] == "MockResponseModel"
|
||||
assert "properties" in schema
|
||||
assert "a" in schema["properties"]
|
||||
assert schema["properties"]["a"]["type"] == "integer"
|
||||
assert "b" in schema["properties"]
|
||||
assert schema["properties"]["b"]["type"] == "string"
|
||||
|
||||
assert kwargs["messages"][0].content == "Return an object with fields a and b."
|
||||
assert kwargs["model"] == model_id
|
||||
|
||||
|
||||
# endregion chat completion
|
||||
|
||||
|
||||
@@ -594,4 +644,51 @@ async def test_azure_ai_inference_streaming_chat_completion_with_function_choice
|
||||
)
|
||||
|
||||
|
||||
class MockStreamingResponseModel(BaseModel):
|
||||
foo: float = Field(..., description="Foo value")
|
||||
bar: bool = Field(..., description="Bar value")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"azure_ai_inference_service",
|
||||
[AzureAIInferenceChatCompletion.__name__],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(ChatCompletionsClient, "complete", new_callable=AsyncMock)
|
||||
async def test_azure_ai_inference_streaming_response_format_json_schema(
|
||||
mock_complete,
|
||||
azure_ai_inference_service,
|
||||
model_id,
|
||||
chat_history: ChatHistory,
|
||||
mock_azure_ai_inference_streaming_chat_completion_response,
|
||||
):
|
||||
chat_history.add_user_message("Stream a response with foo and bar.")
|
||||
settings = AzureAIInferenceChatPromptExecutionSettings(
|
||||
response_format=MockStreamingResponseModel,
|
||||
structured_json_response=True,
|
||||
)
|
||||
|
||||
mock_complete.return_value = mock_azure_ai_inference_streaming_chat_completion_response
|
||||
|
||||
messages = []
|
||||
async for chunk in azure_ai_inference_service.get_streaming_chat_message_contents(chat_history, settings):
|
||||
messages.extend(chunk)
|
||||
|
||||
assert mock_complete.call_count == 1
|
||||
kwargs = mock_complete.call_args.kwargs
|
||||
assert "response_format" in kwargs
|
||||
response_format = kwargs["response_format"]
|
||||
assert isinstance(response_format, JsonSchemaFormat)
|
||||
assert response_format.name == "MockStreamingResponseModel"
|
||||
assert response_format.strict is True
|
||||
schema = response_format.schema
|
||||
assert schema["title"] == "MockStreamingResponseModel"
|
||||
assert "foo" in schema["properties"]
|
||||
assert schema["properties"]["foo"]["type"] == "number"
|
||||
assert "bar" in schema["properties"]
|
||||
assert schema["properties"]["bar"]["type"] == "boolean"
|
||||
assert kwargs["stream"] is True
|
||||
assert kwargs["model"] == model_id
|
||||
|
||||
|
||||
# endregion streaming chat completion
|
||||
|
||||
Reference in New Issue
Block a user