kosong.message
1from abc import ABC 2from typing import Any, ClassVar, Literal, cast, override 3 4from pydantic import BaseModel, GetCoreSchemaHandler, field_serializer, field_validator 5from pydantic_core import core_schema 6 7from kosong.utils.typing import JsonType 8 9 10class MergeableMixin: 11 def merge_in_place(self, other: Any) -> bool: 12 """Merge the other part into the current part. Return True if the merge is successful.""" 13 return False 14 15 16class ContentPart(BaseModel, ABC, MergeableMixin): 17 """ 18 A part of a message content. 19 20 This is the abstract base class for all supported content parts. Subclasses must define a `type` 21 field of type `str` and optional other fields specific to the content part. 22 23 For Kosong users, you typically do not need to subclass this directly. Instead, use the provided 24 subclasses like `TextPart`, `ThinkPart`, `ImageURLPart`, etc. Unless you are implementing custom 25 `ChatProvider`s that supports new content part types. 26 """ 27 28 __content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {} 29 30 type: str 31 ... # to be added by subclasses 32 33 def __init_subclass__(cls, **kwargs: Any) -> None: 34 super().__init_subclass__(**kwargs) 35 36 invalid_subclass_error_msg = ( 37 f"ContentPart subclass {cls.__name__} must have a `type` field of type `str`" 38 ) 39 40 type_value = getattr(cls, "type", None) 41 if type_value is None or not isinstance(type_value, str): 42 raise ValueError(invalid_subclass_error_msg) 43 44 cls.__content_part_registry[type_value] = cls 45 46 @classmethod 47 def __get_pydantic_core_schema__( 48 cls, source_type: Any, handler: GetCoreSchemaHandler 49 ) -> core_schema.CoreSchema: 50 # If we're dealing with the base ContentPart class, use custom validation 51 if cls.__name__ == "ContentPart": 52 53 def validate_content_part(value: Any) -> Any: 54 # if it's already an instance of a ContentPart subclass, return it 55 if hasattr(value, "__class__") and issubclass(value.__class__, cls): 56 return value 57 58 # if it's a dict with a type field, dispatch to the appropriate subclass 59 if isinstance(value, dict) and "type" in value: 60 type_value: Any | None = cast(dict[str, Any], value).get("type") 61 if not isinstance(type_value, str): 62 raise ValueError(f"Cannot validate {value} as ContentPart") 63 target_class = cls.__content_part_registry[type_value] 64 return target_class.model_validate(value) 65 66 raise ValueError(f"Cannot validate {value} as ContentPart") 67 68 return core_schema.no_info_plain_validator_function(validate_content_part) 69 70 # for subclasses, use the default schema 71 return handler(source_type) 72 73 74class TextPart(ContentPart): 75 """ 76 >>> TextPart(text="Hello, world!").model_dump() 77 {'type': 'text', 'text': 'Hello, world!'} 78 """ 79 80 type: str = "text" 81 text: str 82 83 @override 84 def merge_in_place(self, other: Any) -> bool: 85 if not isinstance(other, TextPart): 86 return False 87 self.text += other.text 88 return True 89 90 91class ThinkPart(ContentPart): 92 """ 93 >>> ThinkPart(think="I think I need to think about this.").model_dump() 94 {'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None} 95 """ 96 97 type: str = "think" 98 think: str 99 encrypted: str | None = None 100 """Encrypted thinking content, or signature.""" 101 102 @override 103 def merge_in_place(self, other: Any) -> bool: 104 if not isinstance(other, ThinkPart): 105 return False 106 if self.encrypted: 107 return False 108 self.think += other.think 109 if other.encrypted: 110 self.encrypted = other.encrypted 111 return True 112 113 114class ImageURLPart(ContentPart): 115 """ 116 >>> ImageURLPart( 117 ... image_url=ImageURLPart.ImageURL(url="https://example.com/image.png") 118 ... ).model_dump() 119 {'type': 'image_url', 'image_url': {'url': 'https://example.com/image.png', 'id': None}} 120 """ 121 122 class ImageURL(BaseModel): 123 """Image URL payload.""" 124 125 url: str 126 """The URL of the image, can be data URI scheme like `data:image/png;base64,...`.""" 127 id: str | None = None 128 """The ID of the image, to allow LLMs to distinguish different images.""" 129 130 type: str = "image_url" 131 image_url: ImageURL 132 133 134class AudioURLPart(ContentPart): 135 """ 136 >>> AudioURLPart( 137 ... audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3") 138 ... ).model_dump() 139 {'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}} 140 """ 141 142 class AudioURL(BaseModel): 143 """Audio URL payload.""" 144 145 url: str 146 """The URL of the audio, can be data URI scheme like `data:audio/aac;base64,...`.""" 147 id: str | None = None 148 """The ID of the audio, to allow LLMs to distinguish different audios.""" 149 150 type: str = "audio_url" 151 audio_url: AudioURL 152 153 154class VideoURLPart(ContentPart): 155 """ 156 >>> VideoURLPart( 157 ... video_url=VideoURLPart.VideoURL(url="https://example.com/video.mp4") 158 ... ).model_dump() 159 {'type': 'video_url', 'video_url': {'url': 'https://example.com/video.mp4', 'id': None}} 160 """ 161 162 class VideoURL(BaseModel): 163 """Video URL payload.""" 164 165 url: str 166 """The URL of the video, can be data URI scheme like `data:video/mp4;base64,...`.""" 167 id: str | None = None 168 """The ID of the video, to allow LLMs to distinguish different videos.""" 169 170 type: str = "video_url" 171 video_url: VideoURL 172 173 174class ToolCall(BaseModel, MergeableMixin): 175 """ 176 A tool call requested by the assistant. 177 178 >>> ToolCall( 179 ... id="123", 180 ... function=ToolCall.FunctionBody(name="function", arguments="{}"), 181 ... ).model_dump(exclude_none=True) 182 {'type': 'function', 'id': '123', 'function': {'name': 'function', 'arguments': '{}'}} 183 """ 184 185 class FunctionBody(BaseModel): 186 """Tool call function body.""" 187 188 name: str 189 """The name of the tool to be called.""" 190 arguments: str | None 191 """Arguments of the tool call in JSON string format.""" 192 193 type: Literal["function"] = "function" 194 195 id: str 196 """The ID of the tool call.""" 197 function: FunctionBody 198 """The function body of the tool call.""" 199 extras: dict[str, JsonType] | None = None 200 """Extra information about the tool call.""" 201 202 @override 203 def merge_in_place(self, other: Any) -> bool: 204 if not isinstance(other, ToolCallPart): 205 return False 206 if self.function.arguments is None: 207 self.function.arguments = other.arguments_part 208 else: 209 self.function.arguments += other.arguments_part or "" 210 return True 211 212 213class ToolCallPart(BaseModel, MergeableMixin): 214 """A part of the tool call.""" 215 216 arguments_part: str | None = None 217 """A part of the arguments of the tool call.""" 218 219 @override 220 def merge_in_place(self, other: Any) -> bool: 221 if not isinstance(other, ToolCallPart): 222 return False 223 if self.arguments_part is None: 224 self.arguments_part = other.arguments_part 225 else: 226 self.arguments_part += other.arguments_part or "" 227 return True 228 229 230type Role = Literal[ 231 # for OpenAI API, this should be converted to `developer` 232 # OpenAI & Kimi support system messages in the middle of the conversation. 233 # Anthropic only support system messages at the beginning https://docs.claude.com/en/api/messages#body-messages 234 # In this case, we map `system` message to a `user` message wrapped in `<system></system>` tags. 235 "system", 236 "user", 237 "assistant", 238 "tool", 239] 240"""The role of a message sender.""" 241 242 243class Message(BaseModel): 244 """A message in a conversation.""" 245 246 role: Role 247 """The role of the message sender.""" 248 249 name: str | None = None 250 251 content: list[ContentPart] 252 """ 253 The content of the message. 254 Empty list `[]` will be interpreted as no content. 255 """ 256 257 tool_calls: list[ToolCall] | None = None 258 """Tool calls requested by the assistant in this message.""" 259 260 tool_call_id: str | None = None 261 """The ID of the tool call if this message is a tool response.""" 262 263 partial: bool | None = None 264 265 @field_serializer("content") 266 def _serialize_content(self, content: list[ContentPart]) -> str | list[dict[str, Any]] | None: 267 if len(content) == 1 and isinstance(content[0], TextPart): 268 return content[0].text 269 return [part.model_dump() for part in content] 270 271 @field_validator("content", mode="before") 272 @classmethod 273 def _coerce_none_content(cls, value: Any) -> Any: 274 if value is None: 275 return [] 276 if isinstance(value, str): 277 return [TextPart(text=value)] 278 return value 279 280 def __init__( 281 self, 282 *, 283 role: Role, 284 content: list[ContentPart] | ContentPart | str, 285 tool_calls: list[ToolCall] | None = None, 286 tool_call_id: str | None = None, 287 **data: Any, 288 ) -> None: 289 if isinstance(content, str): 290 content = [TextPart(text=content)] 291 elif isinstance(content, ContentPart): 292 content = [content] 293 super().__init__( 294 role=role, 295 content=content, 296 tool_calls=tool_calls, 297 tool_call_id=tool_call_id, 298 **data, 299 ) 300 301 def extract_text(self, sep: str = "") -> str: 302 """Extract and concatenate all text parts in the message content.""" 303 return sep.join(part.text for part in self.content if isinstance(part, TextPart))
17class ContentPart(BaseModel, ABC, MergeableMixin): 18 """ 19 A part of a message content. 20 21 This is the abstract base class for all supported content parts. Subclasses must define a `type` 22 field of type `str` and optional other fields specific to the content part. 23 24 For Kosong users, you typically do not need to subclass this directly. Instead, use the provided 25 subclasses like `TextPart`, `ThinkPart`, `ImageURLPart`, etc. Unless you are implementing custom 26 `ChatProvider`s that supports new content part types. 27 """ 28 29 __content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {} 30 31 type: str 32 ... # to be added by subclasses 33 34 def __init_subclass__(cls, **kwargs: Any) -> None: 35 super().__init_subclass__(**kwargs) 36 37 invalid_subclass_error_msg = ( 38 f"ContentPart subclass {cls.__name__} must have a `type` field of type `str`" 39 ) 40 41 type_value = getattr(cls, "type", None) 42 if type_value is None or not isinstance(type_value, str): 43 raise ValueError(invalid_subclass_error_msg) 44 45 cls.__content_part_registry[type_value] = cls 46 47 @classmethod 48 def __get_pydantic_core_schema__( 49 cls, source_type: Any, handler: GetCoreSchemaHandler 50 ) -> core_schema.CoreSchema: 51 # If we're dealing with the base ContentPart class, use custom validation 52 if cls.__name__ == "ContentPart": 53 54 def validate_content_part(value: Any) -> Any: 55 # if it's already an instance of a ContentPart subclass, return it 56 if hasattr(value, "__class__") and issubclass(value.__class__, cls): 57 return value 58 59 # if it's a dict with a type field, dispatch to the appropriate subclass 60 if isinstance(value, dict) and "type" in value: 61 type_value: Any | None = cast(dict[str, Any], value).get("type") 62 if not isinstance(type_value, str): 63 raise ValueError(f"Cannot validate {value} as ContentPart") 64 target_class = cls.__content_part_registry[type_value] 65 return target_class.model_validate(value) 66 67 raise ValueError(f"Cannot validate {value} as ContentPart") 68 69 return core_schema.no_info_plain_validator_function(validate_content_part) 70 71 # for subclasses, use the default schema 72 return handler(source_type)
A part of a message content.
This is the abstract base class for all supported content parts. Subclasses must define a type
field of type str and optional other fields specific to the content part.
For Kosong users, you typically do not need to subclass this directly. Instead, use the provided
subclasses like TextPart, ThinkPart, ImageURLPart, etc. Unless you are implementing custom
ChatProviders that supports new content part types.
Inherited Members
75class TextPart(ContentPart): 76 """ 77 >>> TextPart(text="Hello, world!").model_dump() 78 {'type': 'text', 'text': 'Hello, world!'} 79 """ 80 81 type: str = "text" 82 text: str 83 84 @override 85 def merge_in_place(self, other: Any) -> bool: 86 if not isinstance(other, TextPart): 87 return False 88 self.text += other.text 89 return True
>>> TextPart(text="Hello, world!").model_dump()
{'type': 'text', 'text': 'Hello, world!'}
92class ThinkPart(ContentPart): 93 """ 94 >>> ThinkPart(think="I think I need to think about this.").model_dump() 95 {'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None} 96 """ 97 98 type: str = "think" 99 think: str 100 encrypted: str | None = None 101 """Encrypted thinking content, or signature.""" 102 103 @override 104 def merge_in_place(self, other: Any) -> bool: 105 if not isinstance(other, ThinkPart): 106 return False 107 if self.encrypted: 108 return False 109 self.think += other.think 110 if other.encrypted: 111 self.encrypted = other.encrypted 112 return True
>>> ThinkPart(think="I think I need to think about this.").model_dump()
{'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
103 @override 104 def merge_in_place(self, other: Any) -> bool: 105 if not isinstance(other, ThinkPart): 106 return False 107 if self.encrypted: 108 return False 109 self.think += other.think 110 if other.encrypted: 111 self.encrypted = other.encrypted 112 return True
Merge the other part into the current part. Return True if the merge is successful.
115class ImageURLPart(ContentPart): 116 """ 117 >>> ImageURLPart( 118 ... image_url=ImageURLPart.ImageURL(url="https://example.com/image.png") 119 ... ).model_dump() 120 {'type': 'image_url', 'image_url': {'url': 'https://example.com/image.png', 'id': None}} 121 """ 122 123 class ImageURL(BaseModel): 124 """Image URL payload.""" 125 126 url: str 127 """The URL of the image, can be data URI scheme like `data:image/png;base64,...`.""" 128 id: str | None = None 129 """The ID of the image, to allow LLMs to distinguish different images.""" 130 131 type: str = "image_url" 132 image_url: ImageURL
>>> ImageURLPart(
... image_url=ImageURLPart.ImageURL(url="https://example.com/image.png")
... ).model_dump()
{'type': 'image_url', 'image_url': {'url': 'https://example.com/image.png', 'id': None}}
Inherited Members
123 class ImageURL(BaseModel): 124 """Image URL payload.""" 125 126 url: str 127 """The URL of the image, can be data URI scheme like `data:image/png;base64,...`.""" 128 id: str | None = None 129 """The ID of the image, to allow LLMs to distinguish different images."""
Image URL payload.
135class AudioURLPart(ContentPart): 136 """ 137 >>> AudioURLPart( 138 ... audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3") 139 ... ).model_dump() 140 {'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}} 141 """ 142 143 class AudioURL(BaseModel): 144 """Audio URL payload.""" 145 146 url: str 147 """The URL of the audio, can be data URI scheme like `data:audio/aac;base64,...`.""" 148 id: str | None = None 149 """The ID of the audio, to allow LLMs to distinguish different audios.""" 150 151 type: str = "audio_url" 152 audio_url: AudioURL
>>> AudioURLPart(
... audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")
... ).model_dump()
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
Inherited Members
143 class AudioURL(BaseModel): 144 """Audio URL payload.""" 145 146 url: str 147 """The URL of the audio, can be data URI scheme like `data:audio/aac;base64,...`.""" 148 id: str | None = None 149 """The ID of the audio, to allow LLMs to distinguish different audios."""
Audio URL payload.
155class VideoURLPart(ContentPart): 156 """ 157 >>> VideoURLPart( 158 ... video_url=VideoURLPart.VideoURL(url="https://example.com/video.mp4") 159 ... ).model_dump() 160 {'type': 'video_url', 'video_url': {'url': 'https://example.com/video.mp4', 'id': None}} 161 """ 162 163 class VideoURL(BaseModel): 164 """Video URL payload.""" 165 166 url: str 167 """The URL of the video, can be data URI scheme like `data:video/mp4;base64,...`.""" 168 id: str | None = None 169 """The ID of the video, to allow LLMs to distinguish different videos.""" 170 171 type: str = "video_url" 172 video_url: VideoURL
>>> VideoURLPart(
... video_url=VideoURLPart.VideoURL(url="https://example.com/video.mp4")
... ).model_dump()
{'type': 'video_url', 'video_url': {'url': 'https://example.com/video.mp4', 'id': None}}
Inherited Members
163 class VideoURL(BaseModel): 164 """Video URL payload.""" 165 166 url: str 167 """The URL of the video, can be data URI scheme like `data:video/mp4;base64,...`.""" 168 id: str | None = None 169 """The ID of the video, to allow LLMs to distinguish different videos."""
Video URL payload.
175class ToolCall(BaseModel, MergeableMixin): 176 """ 177 A tool call requested by the assistant. 178 179 >>> ToolCall( 180 ... id="123", 181 ... function=ToolCall.FunctionBody(name="function", arguments="{}"), 182 ... ).model_dump(exclude_none=True) 183 {'type': 'function', 'id': '123', 'function': {'name': 'function', 'arguments': '{}'}} 184 """ 185 186 class FunctionBody(BaseModel): 187 """Tool call function body.""" 188 189 name: str 190 """The name of the tool to be called.""" 191 arguments: str | None 192 """Arguments of the tool call in JSON string format.""" 193 194 type: Literal["function"] = "function" 195 196 id: str 197 """The ID of the tool call.""" 198 function: FunctionBody 199 """The function body of the tool call.""" 200 extras: dict[str, JsonType] | None = None 201 """Extra information about the tool call.""" 202 203 @override 204 def merge_in_place(self, other: Any) -> bool: 205 if not isinstance(other, ToolCallPart): 206 return False 207 if self.function.arguments is None: 208 self.function.arguments = other.arguments_part 209 else: 210 self.function.arguments += other.arguments_part or "" 211 return True
A tool call requested by the assistant.
>>> ToolCall(
... id="123",
... function=ToolCall.FunctionBody(name="function", arguments="{}"),
... ).model_dump(exclude_none=True)
{'type': 'function', 'id': '123', 'function': {'name': 'function', 'arguments': '{}'}}
203 @override 204 def merge_in_place(self, other: Any) -> bool: 205 if not isinstance(other, ToolCallPart): 206 return False 207 if self.function.arguments is None: 208 self.function.arguments = other.arguments_part 209 else: 210 self.function.arguments += other.arguments_part or "" 211 return True
Merge the other part into the current part. Return True if the merge is successful.
186 class FunctionBody(BaseModel): 187 """Tool call function body.""" 188 189 name: str 190 """The name of the tool to be called.""" 191 arguments: str | None 192 """Arguments of the tool call in JSON string format."""
Tool call function body.
214class ToolCallPart(BaseModel, MergeableMixin): 215 """A part of the tool call.""" 216 217 arguments_part: str | None = None 218 """A part of the arguments of the tool call.""" 219 220 @override 221 def merge_in_place(self, other: Any) -> bool: 222 if not isinstance(other, ToolCallPart): 223 return False 224 if self.arguments_part is None: 225 self.arguments_part = other.arguments_part 226 else: 227 self.arguments_part += other.arguments_part or "" 228 return True
A part of the tool call.
220 @override 221 def merge_in_place(self, other: Any) -> bool: 222 if not isinstance(other, ToolCallPart): 223 return False 224 if self.arguments_part is None: 225 self.arguments_part = other.arguments_part 226 else: 227 self.arguments_part += other.arguments_part or "" 228 return True
Merge the other part into the current part. Return True if the merge is successful.
The role of a message sender.
244class Message(BaseModel): 245 """A message in a conversation.""" 246 247 role: Role 248 """The role of the message sender.""" 249 250 name: str | None = None 251 252 content: list[ContentPart] 253 """ 254 The content of the message. 255 Empty list `[]` will be interpreted as no content. 256 """ 257 258 tool_calls: list[ToolCall] | None = None 259 """Tool calls requested by the assistant in this message.""" 260 261 tool_call_id: str | None = None 262 """The ID of the tool call if this message is a tool response.""" 263 264 partial: bool | None = None 265 266 @field_serializer("content") 267 def _serialize_content(self, content: list[ContentPart]) -> str | list[dict[str, Any]] | None: 268 if len(content) == 1 and isinstance(content[0], TextPart): 269 return content[0].text 270 return [part.model_dump() for part in content] 271 272 @field_validator("content", mode="before") 273 @classmethod 274 def _coerce_none_content(cls, value: Any) -> Any: 275 if value is None: 276 return [] 277 if isinstance(value, str): 278 return [TextPart(text=value)] 279 return value 280 281 def __init__( 282 self, 283 *, 284 role: Role, 285 content: list[ContentPart] | ContentPart | str, 286 tool_calls: list[ToolCall] | None = None, 287 tool_call_id: str | None = None, 288 **data: Any, 289 ) -> None: 290 if isinstance(content, str): 291 content = [TextPart(text=content)] 292 elif isinstance(content, ContentPart): 293 content = [content] 294 super().__init__( 295 role=role, 296 content=content, 297 tool_calls=tool_calls, 298 tool_call_id=tool_call_id, 299 **data, 300 ) 301 302 def extract_text(self, sep: str = "") -> str: 303 """Extract and concatenate all text parts in the message content.""" 304 return sep.join(part.text for part in self.content if isinstance(part, TextPart))
A message in a conversation.
302 def extract_text(self, sep: str = "") -> str: 303 """Extract and concatenate all text parts in the message content.""" 304 return sep.join(part.text for part in self.content if isinstance(part, TextPart))
Extract and concatenate all text parts in the message content.