Skip to content

Commit 75089cb

Browse files
Python: Enhance Chat Message Content creation and parsing (microsoft#5520)
### Motivation and Context <!-- 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. --> Introduces a polymorphic approach to creating and rendering Chat Message Contents and it's subclasses, leverages Pydantic Rootmodel for that. Fixes microsoft#5496 ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> Adds ChatMessageContentBase that can create all types of CMC's, based on a type field (str) Redoes some of the rendering and parsing also retooled the hierarchy structure for Contents, including introducing a StreamingContentMixin instead of the StreamingKernelContent (which was never used), now all content classes (TextContent, CMC, etc) determine the fields used, while the Mixin adds one additional field and two additional methods for streaming messages, especially __add__ everything else runs the same way. ### 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 😄
1 parent 1924141 commit 75089cb

26 files changed

Lines changed: 502 additions & 317 deletions

‎python/semantic_kernel/connectors/ai/chat_completion_client_base.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33
from abc import ABC, abstractmethod
4-
from typing import TYPE_CHECKING, Any, AsyncIterable, Dict, List, Optional, Type
4+
from typing import TYPE_CHECKING, Any, AsyncIterable, Dict, List, Optional
55

66
from semantic_kernel.contents import ChatMessageContent
77
from semantic_kernel.services.ai_service_client_base import AIServiceClientBase
@@ -13,9 +13,9 @@
1313

1414

1515
class ChatCompletionClientBase(AIServiceClientBase, ABC):
16-
def get_chat_message_content_class(self) -> Type[ChatMessageContent]:
17-
"""Get the chat message content types used by a class, default is ChatMessageContent."""
18-
return ChatMessageContent
16+
def get_chat_message_content_type(self) -> str:
17+
"""Get the chat message content types used by a class, default is 'ChatMessageContent'."""
18+
return "ChatMessageContent"
1919

2020
@abstractmethod
2121
async def complete_chat(

‎python/semantic_kernel/connectors/ai/ollama/services/ollama_chat_completion.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ async def complete_chat_stream(
106106
inner_content=body,
107107
ai_model_id=self.ai_model_id,
108108
content=body.get("message", {"content": None}).get("content", None),
109+
role="assistant",
109110
)
110111
]
111112
if body.get("done"):

‎python/semantic_kernel/connectors/ai/open_ai/contents/azure_chat_message_content.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Copyright (c) Microsoft. All rights reserved.
2-
from typing import Optional
2+
from typing import Literal, Optional
33

44
from semantic_kernel.connectors.ai.open_ai.contents.open_ai_chat_message_content import OpenAIChatMessageContent
5+
from semantic_kernel.contents.const import AZURE_CHAT_MESSAGE_CONTENT
56

67

78
class AzureChatMessageContent(OpenAIChatMessageContent):
@@ -24,4 +25,5 @@ class AzureChatMessageContent(OpenAIChatMessageContent):
2425
__str__: Returns the content of the response.
2526
"""
2627

28+
type: Literal[AZURE_CHAT_MESSAGE_CONTENT] = AZURE_CHAT_MESSAGE_CONTENT # type: ignore
2729
tool_message: Optional[str] = None

‎python/semantic_kernel/connectors/ai/open_ai/contents/azure_streaming_chat_message_content.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
# Copyright (c) Microsoft. All rights reserved.
2-
from typing import Optional
2+
from typing import Any
33

4-
from semantic_kernel.connectors.ai.open_ai.contents.open_ai_streaming_chat_message_content import (
5-
OpenAIStreamingChatMessageContent,
6-
)
4+
from semantic_kernel.connectors.ai.open_ai.contents.azure_chat_message_content import AzureChatMessageContent
5+
from semantic_kernel.contents.streaming_content_mixin import StreamingContentMixin
76
from semantic_kernel.exceptions import ContentAdditionException
87

98

10-
class AzureStreamingChatMessageContent(OpenAIStreamingChatMessageContent):
9+
class AzureStreamingChatMessageContent(StreamingContentMixin, AzureChatMessageContent):
1110
"""This is the class for Azure OpenAI streaming chat message response content.
1211
1312
The end-user will have to either do something directly or gather them and combine them into a
@@ -33,15 +32,18 @@ class AzureStreamingChatMessageContent(OpenAIStreamingChatMessageContent):
3332
__add__: Combines two StreamingChatMessageContent instances.
3433
"""
3534

36-
tool_message: Optional[str] = None
35+
def __bytes__(self) -> bytes:
36+
return self.content.encode(self.encoding if self.encoding else "utf-8") if self.content else b""
3737

38-
def __add__(self, other: "AzureStreamingChatMessageContent") -> "AzureStreamingChatMessageContent":
38+
def __add__(self, other: Any) -> "AzureStreamingChatMessageContent":
3939
"""When combining two AzureOpenAIStreamingChatMessageContent instances,
4040
the content fields are combined, as well as the arguments of the function or tool calls.
4141
4242
The inner_content of the first one is used, ai_model_id and encoding should be the same,
4343
if role is set, they should be the same.
4444
"""
45+
if not isinstance(other, AzureStreamingChatMessageContent):
46+
return self
4547
if self.choice_index != other.choice_index:
4648
raise ContentAdditionException("Cannot add StreamingChatMessageContent with different choice_index")
4749
if self.ai_model_id != other.ai_model_id:
Lines changed: 26 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
# Copyright (c) Microsoft. All rights reserved.
2-
from typing import List, Optional
3-
from xml.etree.ElementTree import Element
2+
from typing import Any, List, Literal, Optional
43

5-
from openai.types.chat import ChatCompletion
4+
from pydantic import field_validator
65

76
from semantic_kernel.connectors.ai.open_ai.contents.function_call import FunctionCall
87
from semantic_kernel.connectors.ai.open_ai.contents.tool_calls import ToolCall
98
from semantic_kernel.contents import ChatMessageContent
10-
from semantic_kernel.contents.chat_role import ChatRole
9+
from semantic_kernel.contents.const import OPENAI_CHAT_MESSAGE_CONTENT
1110

1211

1312
class OpenAIChatMessageContent(ChatMessageContent):
@@ -29,49 +28,34 @@ class OpenAIChatMessageContent(ChatMessageContent):
2928
__str__: Returns the content of the response.
3029
"""
3130

32-
inner_content: Optional[ChatCompletion] = None
31+
type: Literal[OPENAI_CHAT_MESSAGE_CONTENT] = OPENAI_CHAT_MESSAGE_CONTENT # type: ignore
3332
function_call: Optional[FunctionCall] = None
3433
tool_calls: Optional[List[ToolCall]] = None
3534
tool_call_id: Optional[str] = None
3635

36+
@field_validator("tool_calls", mode="before")
37+
@classmethod
38+
def _validate_tool_calls(cls, tool_calls: Any) -> Optional[List[ToolCall]]:
39+
if not tool_calls:
40+
return None
41+
if isinstance(tool_calls, list):
42+
for index, call in enumerate(tool_calls):
43+
if not isinstance(call, ToolCall):
44+
tool_calls[index] = ToolCall.model_validate_json(call)
45+
return tool_calls
46+
if isinstance(tool_calls, str):
47+
return [ToolCall.model_validate_json(call) for call in tool_calls.split("|")]
48+
49+
@field_validator("function_call", mode="before")
50+
@classmethod
51+
def _validate_function_call(cls, function_call: Any) -> Optional[FunctionCall]:
52+
if not function_call:
53+
return None
54+
if isinstance(function_call, FunctionCall):
55+
return function_call
56+
return FunctionCall.model_validate_json(function_call)
57+
3758
@staticmethod
3859
def ToolIdProperty():
3960
# Directly using the class name and the attribute name as strings
4061
return f"{ToolCall.__name__}.{ToolCall.id.__name__}"
41-
42-
def to_element(self, root_key: str) -> Element:
43-
"""Convert the OpenAIChatMessageContent to a prompt.
44-
45-
Returns:
46-
str - The prompt from the ChatMessageContent.
47-
"""
48-
49-
root = Element(root_key)
50-
root.set("role", self.role.value)
51-
if self.function_call:
52-
root.set("function_call", self.function_call.model_dump_json(exclude_none=True))
53-
if self.tool_calls:
54-
root.set("tool_calls", "|".join([call.model_dump_json(exclude_none=True) for call in self.tool_calls]))
55-
if self.tool_call_id:
56-
root.set("tool_call_id", self.tool_call_id)
57-
root.text = self.content or ""
58-
return root
59-
60-
@classmethod
61-
def from_element(cls, element: Element) -> "ChatMessageContent":
62-
"""Create a new instance of OpenAIChatMessageContent from a prompt.
63-
64-
Args:
65-
prompt: str - The prompt to create the ChatMessageContent from.
66-
67-
Returns:
68-
ChatMessageContent - The new instance of ChatMessageContent.
69-
"""
70-
args = {"role": element.get("role", ChatRole.USER.value), "content": element.text}
71-
if function_call := element.get("function_call"):
72-
args["function_call"] = FunctionCall.model_validate_json(function_call)
73-
if tool_calls := element.get("tool_calls"):
74-
args["tool_calls"] = [ToolCall.model_validate_json(call) for call in tool_calls.split("|")]
75-
if tool_call_id := element.get("tool_call_id"):
76-
args["tool_call_id"] = tool_call_id
77-
return cls(**args)
Lines changed: 9 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
# Copyright (c) Microsoft. All rights reserved.
2-
from typing import List, Optional
3-
from xml.etree.ElementTree import Element
42

5-
from defusedxml import ElementTree
6-
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
3+
from typing import Any
74

8-
from semantic_kernel.connectors.ai.open_ai.contents.function_call import FunctionCall
9-
from semantic_kernel.connectors.ai.open_ai.contents.tool_calls import ToolCall
10-
from semantic_kernel.contents import StreamingChatMessageContent
11-
from semantic_kernel.contents.chat_role import ChatRole
5+
from semantic_kernel.connectors.ai.open_ai.contents.open_ai_chat_message_content import OpenAIChatMessageContent
6+
from semantic_kernel.contents.streaming_content_mixin import StreamingContentMixin
127
from semantic_kernel.exceptions import ContentAdditionException
138

149

15-
class OpenAIStreamingChatMessageContent(StreamingChatMessageContent):
10+
class OpenAIStreamingChatMessageContent(StreamingContentMixin, OpenAIChatMessageContent):
1611
"""This is the class for OpenAI streaming chat message response content.
1712
1813
The end-user will have to either do something directly or gather them and combine them into a
@@ -37,18 +32,18 @@ class OpenAIStreamingChatMessageContent(StreamingChatMessageContent):
3732
__add__: Combines two StreamingChatMessageContent instances.
3833
"""
3934

40-
inner_content: ChatCompletionChunk
41-
function_call: Optional[FunctionCall] = None
42-
tool_calls: Optional[List[ToolCall]] = None
43-
tool_call_id: Optional[str] = None
35+
def __bytes__(self) -> bytes:
36+
return self.content.encode(self.encoding if self.encoding else "utf-8") if self.content else b""
4437

45-
def __add__(self, other: "OpenAIStreamingChatMessageContent") -> "OpenAIStreamingChatMessageContent":
38+
def __add__(self, other: Any) -> "OpenAIStreamingChatMessageContent":
4639
"""When combining two OpenAIStreamingChatMessageContent instances,
4740
the content fields are combined, as well as the arguments of the function or tool calls.
4841
4942
The inner_content of the first one is used, ai_model_id and encoding should be the same,
5043
if role is set, they should be the same.
5144
"""
45+
if not isinstance(other, OpenAIStreamingChatMessageContent):
46+
return self
5247
if self.choice_index != other.choice_index:
5348
raise ContentAdditionException("Cannot add StreamingChatMessageContent with different choice_index")
5449
if self.ai_model_id != other.ai_model_id:
@@ -85,41 +80,3 @@ def __add__(self, other: "OpenAIStreamingChatMessageContent") -> "OpenAIStreamin
8580
tool_calls=tc_list,
8681
tool_call_id=self.tool_call_id or other.tool_call_id,
8782
)
88-
89-
def to_prompt(self, root_key: str) -> str:
90-
"""Convert the OpenAIChatMessageContent to a prompt.
91-
92-
Returns:
93-
str - The prompt from the ChatMessageContent.
94-
"""
95-
96-
root = Element(root_key)
97-
if self.role:
98-
root.set("role", self.role.value)
99-
if self.function_call:
100-
root.set("function_call", self.function_call.model_dump_json(exclude_none=True))
101-
if self.tool_calls:
102-
root.set("tool_calls", "|".join([call.model_dump_json(exclude_none=True) for call in self.tool_calls]))
103-
if self.tool_call_id:
104-
root.set("tool_call_id", self.tool_call_id)
105-
root.text = self.content or ""
106-
return ElementTree.tostring(root, encoding=self.encoding or "unicode", short_empty_elements=False)
107-
108-
@classmethod
109-
def from_element(cls, element: Element) -> "StreamingChatMessageContent":
110-
"""Create a new instance of OpenAIChatMessageContent from a prompt.
111-
112-
Args:
113-
prompt: str - The prompt to create the ChatMessageContent from.
114-
115-
Returns:
116-
ChatMessageContent - The new instance of ChatMessageContent.
117-
"""
118-
args = {"role": element.get("role", ChatRole.USER.value), "content": element.text}
119-
if function_call := element.get("function_call"):
120-
args["function_call"] = FunctionCall.model_validate_json(function_call)
121-
if tool_calls := element.get("tool_calls"):
122-
args["tool_calls"] = [ToolCall.model_validate_json(call) for call in tool_calls.split("|")]
123-
if tool_call_id := element.get("tool_call_id"):
124-
args["tool_call_id"] = tool_call_id
125-
return cls(**args)

‎python/semantic_kernel/connectors/ai/open_ai/services/azure_chat_completion.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ def _create_streaming_chat_message_content(
280280
inner_content=chunk,
281281
ai_model_id=self.ai_model_id,
282282
metadata=metadata,
283-
role=ChatRole(choice.delta.role) if choice.delta.role is not None else None,
283+
role=ChatRole(choice.delta.role) if choice.delta.role else ChatRole.ASSISTANT,
284284
content=choice.delta.content,
285285
finish_reason=FinishReason(choice.finish_reason) if choice.finish_reason is not None else None,
286286
function_call=self._get_function_call_from_chat_choice(choice),
@@ -300,3 +300,7 @@ def _get_tool_message_from_chat_choice(self, choice: Union[Choice, ChunkChoice])
300300
if "tool" in message["role"]:
301301
return message["content"]
302302
return None
303+
304+
def get_chat_message_content_type(self) -> str:
305+
"""Get the chat message content types used by a class, default is 'ChatMessageContent'."""
306+
return "AzureChatMessageContent"

‎python/semantic_kernel/connectors/ai/open_ai/services/open_ai_chat_completion_base.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44
from copy import copy
5-
from typing import TYPE_CHECKING, Any, AsyncIterable, Dict, List, Optional, Tuple, Type, Union
5+
from typing import TYPE_CHECKING, Any, AsyncIterable, Dict, List, Optional, Tuple, Union
66

77
from openai import AsyncStream
88
from openai.types.chat.chat_completion import ChatCompletion, Choice
@@ -49,9 +49,9 @@ def get_prompt_execution_settings_class(self) -> "PromptExecutionSettings":
4949
"""Create a request settings object."""
5050
return OpenAIChatPromptExecutionSettings
5151

52-
def get_chat_message_content_class(self) -> Type[ChatMessageContent]:
53-
"""Get the chat message content types used by a class, default is ChatMessageContent."""
54-
return OpenAIChatMessageContent
52+
def get_chat_message_content_type(self) -> str:
53+
"""Get the chat message content types used by a class, default is 'ChatMessageContent'."""
54+
return "OpenAIChatMessageContent"
5555

5656
async def complete_chat(
5757
self,
@@ -241,7 +241,7 @@ def _create_streaming_chat_message_content(
241241
inner_content=chunk,
242242
ai_model_id=self.ai_model_id,
243243
metadata=metadata,
244-
role=ChatRole(choice.delta.role) if choice.delta.role else None,
244+
role=ChatRole(choice.delta.role) if choice.delta.role else ChatRole.ASSISTANT,
245245
content=choice.delta.content,
246246
finish_reason=FinishReason(choice.finish_reason) if choice.finish_reason else None,
247247
function_call=self._get_function_call_from_chat_choice(choice),

‎python/semantic_kernel/contents/__init__.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@
22
from semantic_kernel.contents.chat_message_content import ChatMessageContent
33
from semantic_kernel.contents.kernel_content import KernelContent
44
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
5-
from semantic_kernel.contents.streaming_kernel_content import StreamingKernelContent
65
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
76
from semantic_kernel.contents.text_content import TextContent
87

98
__all__ = [
109
"ChatMessageContent",
1110
"KernelContent",
1211
"TextContent",
13-
"StreamingKernelContent",
1412
"StreamingChatMessageContent",
1513
"StreamingTextContent",
1614
]

0 commit comments

Comments
 (0)