Spaces:
Paused
Paused
| # -*- coding: utf-8 -*- | |
| """The DashScope formatter module (OpenAI-compatible format).""" | |
| import base64 | |
| from typing import Any | |
| from fnmatch import fnmatch | |
| from abc import ABC | |
| from pydantic import Field | |
| from ._formatter_base import FormatterBase | |
| from .._logging import logger | |
| from ..message import ( | |
| Msg, | |
| TextBlock, | |
| ThinkingBlock, | |
| ToolResultBlock, | |
| URLSource, | |
| DataBlock, | |
| ToolCallBlock, | |
| Base64Source, | |
| HintBlock, | |
| ) | |
| class _DashScopeFormatterBase(FormatterBase, ABC): | |
| """Base class for DashScope formatters (OpenAI-compatible format), | |
| providing shared data block formatting logic.""" | |
| input_types: list[str] = Field( | |
| default_factory=lambda: [ | |
| "text/plain", | |
| "image/*", | |
| "audio/*", | |
| "video/*", | |
| ], | |
| description=( | |
| "The supported input types, aligned with the model card's " | |
| "``input_types`` field. Media types (non ``text/plain`` / " | |
| "``application/x-thinking`` entries) are used to filter " | |
| "``DataBlock``\\s; ``application/x-thinking`` enables passing " | |
| "``reasoning_content`` back to the API." | |
| ), | |
| ) | |
| def supported_input_media_types(self) -> list[str]: | |
| """Derive supported media types from :attr:`input_types`, excluding | |
| ``text/plain`` and ``application/x-thinking``.""" | |
| return [ | |
| t | |
| for t in self.input_types | |
| if t not in ("text/plain", "application/x-thinking") | |
| ] | |
| def supports_thinking_input(self) -> bool: | |
| """Return ``True`` if ``application/x-thinking`` is listed in | |
| :attr:`input_types`, meaning the model accepts ``reasoning_content`` | |
| in the conversation history.""" | |
| return "application/x-thinking" in self.input_types | |
| def _format_dashscope_data_block( | |
| self, | |
| block: DataBlock, | |
| ) -> dict[str, Any] | None: | |
| """Format a DataBlock into the OpenAI-compatible format for | |
| DashScope API. | |
| Supports: | |
| - Images: ``{"type": "image_url", "image_url": {"url": ...}}`` | |
| - Videos: ``{"type": "video_url", "video_url": {"url": ...}}`` | |
| - Audio: ``{"type": "input_audio", "input_audio": {...}}`` | |
| Args: | |
| block (`DataBlock`): | |
| The DataBlock to format. | |
| Returns: | |
| `dict[str, Any] | None`: | |
| A dictionary representing the formatted DataBlock, or ``None`` | |
| if the media type is unsupported. | |
| """ | |
| if not any( | |
| fnmatch(block.source.media_type, pattern) | |
| for pattern in self.supported_input_media_types | |
| ): | |
| logger.warning( | |
| "Unsupported media type %s for DashScope API. Supported " | |
| "types: %s. This block will be skipped.", | |
| block.source.media_type, | |
| ", ".join(self.supported_input_media_types), | |
| ) | |
| return None | |
| main_type = block.source.media_type.split("/")[0] | |
| if main_type == "image": | |
| return self._format_image_source(block.source) | |
| if main_type == "video": | |
| return self._format_video_source(block.source) | |
| if main_type == "audio": | |
| return self._format_audio_source(block.source) | |
| logger.warning( | |
| "Unsupported main media type %s for DashScope API. " | |
| "This block will be skipped.", | |
| main_type, | |
| ) | |
| return None | |
| def _format_image_source( | |
| source: URLSource | Base64Source, | |
| ) -> dict[str, Any]: | |
| """Convert an image source to OpenAI-compatible ``image_url`` format. | |
| Local ``file://`` URLs are read from disk and converted to base64 | |
| data URIs. Remote URLs are passed through unchanged. | |
| """ | |
| if isinstance(source, Base64Source): | |
| url = f"data:{source.media_type};base64,{source.data}" | |
| elif isinstance(source, URLSource): | |
| url_str = str(source.url) | |
| if url_str.startswith("file://"): | |
| local_path = url_str.removeprefix("file://") | |
| with open(local_path, "rb") as f: | |
| encoded = base64.b64encode(f.read()).decode("utf-8") | |
| url = f"data:{source.media_type};base64,{encoded}" | |
| else: | |
| url = url_str | |
| else: | |
| raise ValueError(f"Unsupported image source type: {type(source)}") | |
| return { | |
| "type": "image_url", | |
| "image_url": {"url": url}, | |
| } | |
| def _format_video_source( | |
| source: URLSource | Base64Source, | |
| ) -> dict[str, Any]: | |
| """Convert a video source to DashScope's ``video_url`` format | |
| (OpenAI-compatible extension). | |
| Local ``file://`` URLs are read from disk and converted to base64 | |
| data URIs. Remote URLs are passed through unchanged. | |
| """ | |
| if isinstance(source, Base64Source): | |
| url = f"data:{source.media_type};base64,{source.data}" | |
| elif isinstance(source, URLSource): | |
| url_str = str(source.url) | |
| if url_str.startswith("file://"): | |
| local_path = url_str.removeprefix("file://") | |
| with open(local_path, "rb") as f: | |
| encoded = base64.b64encode(f.read()).decode("utf-8") | |
| url = f"data:{source.media_type};base64,{encoded}" | |
| else: | |
| url = url_str | |
| else: | |
| raise ValueError(f"Unsupported video source type: {type(source)}") | |
| return { | |
| "type": "video_url", | |
| "video_url": {"url": url}, | |
| } | |
| def _format_audio_source( | |
| source: URLSource | Base64Source, | |
| ) -> dict[str, Any]: | |
| """Convert an audio source to DashScope ``input_audio`` format. | |
| DashScope's compatible API accepts URLs directly in the ``data`` | |
| field (unlike standard OpenAI which requires base64). Local files | |
| are still read and base64-encoded. | |
| """ | |
| if isinstance(source, Base64Source): | |
| fmt = source.media_type.split("/")[-1] | |
| return { | |
| "type": "input_audio", | |
| "input_audio": { | |
| "data": source.data, | |
| "format": fmt, | |
| }, | |
| } | |
| if isinstance(source, URLSource): | |
| url_str = str(source.url) | |
| fmt = source.media_type.split("/")[-1] | |
| if url_str.startswith("file://"): | |
| local_path = url_str.removeprefix("file://") | |
| with open(local_path, "rb") as f: | |
| data = base64.b64encode(f.read()).decode("utf-8") | |
| return { | |
| "type": "input_audio", | |
| "input_audio": { | |
| "data": data, | |
| "format": fmt, | |
| }, | |
| } | |
| else: | |
| return { | |
| "type": "input_audio", | |
| "input_audio": { | |
| "data": url_str, | |
| "format": fmt, | |
| }, | |
| } | |
| raise ValueError(f"Unsupported audio source type: {type(source)}") | |
| class DashScopeChatFormatter(_DashScopeFormatterBase): | |
| """The DashScope formatter class for chatbot scenario (OpenAI-compatible | |
| format), where only a user and an agent are involved. We use the ``role`` | |
| field to identify different entities in the conversation. | |
| This formatter outputs messages in the OpenAI Chat Completions format, | |
| with DashScope-specific extensions for video (``video_url``) and | |
| thinking (``reasoning_content``). | |
| """ | |
| # pylint: disable=too-many-branches | |
| async def format( | |
| self, | |
| msgs: list[Msg], | |
| ) -> list[dict[str, Any]]: | |
| """Format message objects into DashScope OpenAI-compatible format. | |
| Args: | |
| msgs (`list[Msg]`): | |
| The list of message objects to format. | |
| Returns: | |
| `list[dict[str, Any]]`: | |
| The formatted messages as a list of dictionaries. | |
| """ | |
| self.assert_list_of_msgs(msgs) | |
| formatted_msgs: list[dict] = [] | |
| i = 0 | |
| while i < len(msgs): | |
| msg = msgs[i] | |
| content_blocks: list[dict] = [] | |
| tool_calls = [] | |
| thinking_parts: list[str] = [] | |
| for block in msg.get_content_blocks(): | |
| if isinstance(block, TextBlock): | |
| content_blocks.append({"type": "text", "text": block.text}) | |
| elif isinstance(block, DataBlock): | |
| formatted_block = self._format_dashscope_data_block( | |
| block, | |
| ) | |
| if formatted_block: | |
| content_blocks.append(formatted_block) | |
| elif isinstance(block, HintBlock): | |
| if content_blocks or tool_calls or thinking_parts: | |
| msg_openai: dict[str, Any] = { | |
| "role": msg.role, | |
| "content": content_blocks or None, | |
| } | |
| if tool_calls: | |
| msg_openai["tool_calls"] = tool_calls | |
| if thinking_parts: | |
| msg_openai["reasoning_content"] = "\n".join( | |
| thinking_parts, | |
| ) | |
| formatted_msgs.append(msg_openai) | |
| content_blocks = [] | |
| tool_calls = [] | |
| thinking_parts = [] | |
| if isinstance(block.hint, str): | |
| formatted_msgs.append( | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "text", "text": block.hint}, | |
| ], | |
| }, | |
| ) | |
| else: | |
| hint_parts: list[dict] = [] | |
| for sub in block.hint: | |
| if isinstance(sub, TextBlock): | |
| hint_parts.append( | |
| {"type": "text", "text": sub.text}, | |
| ) | |
| elif isinstance(sub, DataBlock): | |
| formatted_sub = ( | |
| self._format_dashscope_data_block( | |
| sub, | |
| ) | |
| ) | |
| if formatted_sub: | |
| hint_parts.append(formatted_sub) | |
| if hint_parts: | |
| formatted_msgs.append( | |
| {"role": "user", "content": hint_parts}, | |
| ) | |
| elif isinstance(block, ToolCallBlock): | |
| tool_calls.append( | |
| { | |
| "id": block.id, | |
| "type": "function", | |
| "function": { | |
| "name": block.name, | |
| "arguments": block.input, | |
| }, | |
| }, | |
| ) | |
| elif isinstance(block, ThinkingBlock): | |
| if self.supports_thinking_input: | |
| thinking_parts.append(block.thinking) | |
| elif isinstance(block, ToolResultBlock): | |
| if content_blocks or tool_calls or thinking_parts: | |
| msg_flush: dict[str, Any] = { | |
| "role": msg.role, | |
| "content": content_blocks or None, | |
| } | |
| if tool_calls: | |
| msg_flush["tool_calls"] = tool_calls | |
| if thinking_parts: | |
| msg_flush["reasoning_content"] = "\n".join( | |
| thinking_parts, | |
| ) | |
| formatted_msgs.append(msg_flush) | |
| content_blocks = [] | |
| tool_calls = [] | |
| thinking_parts = [] | |
| ( | |
| textual_output, | |
| multimodal_data, | |
| ) = self.convert_tool_result_to_string(block.output) | |
| formatted_msgs.append( | |
| { | |
| "role": "tool", | |
| "tool_call_id": block.id, | |
| "content": textual_output, | |
| "name": block.name, | |
| }, | |
| ) | |
| if multimodal_data: | |
| promo_content = [] | |
| for item in multimodal_data: | |
| if isinstance(item, TextBlock): | |
| promo_content.append( | |
| {"type": "text", "text": item.text}, | |
| ) | |
| elif isinstance(item, DataBlock): | |
| fmt_item = self._format_dashscope_data_block( | |
| item, | |
| ) | |
| if fmt_item is not None: | |
| promo_content.append(fmt_item) | |
| if promo_content: | |
| formatted_msgs.append( | |
| { | |
| "role": "user", | |
| "content": promo_content, | |
| }, | |
| ) | |
| else: | |
| logger.warning( | |
| "Unsupported block type %s in the message, skipped.", | |
| type(block), | |
| ) | |
| msg_dashscope: dict[str, Any] = { | |
| "role": msg.role, | |
| "content": content_blocks or None, | |
| } | |
| if tool_calls: | |
| msg_dashscope["tool_calls"] = tool_calls | |
| if thinking_parts: | |
| msg_dashscope["reasoning_content"] = "\n".join(thinking_parts) | |
| if ( | |
| msg_dashscope["content"] | |
| or msg_dashscope.get("tool_calls") | |
| or msg_dashscope.get("reasoning_content") | |
| ): | |
| formatted_msgs.append(msg_dashscope) | |
| i += 1 | |
| return formatted_msgs | |
| class DashScopeMultiAgentFormatter(_DashScopeFormatterBase): | |
| """DashScope formatter for multi-agent conversations (OpenAI-compatible | |
| format), where more than a user and an agent are involved. | |
| .. note:: This formatter will combine previous messages (except tool | |
| calls/results) into a history section in the first system message with | |
| the conversation history prompt. | |
| .. note:: For tool calls/results, they will be presented as separate | |
| messages as required by the API. Therefore, the tool calls/results | |
| messages are expected to be placed at the end of the input messages. | |
| .. tip:: Telling the assistant's name in the system prompt is very | |
| important in multi-agent conversations. So that LLM can know who it | |
| is playing as. | |
| """ | |
| conversation_history_prompt: str = Field( | |
| description="The conversation history prompt.", | |
| default=( | |
| "# Conversation History\n" | |
| "The content between <history></history> tags contains " | |
| "your conversation history\n" | |
| ), | |
| ) | |
| async def format(self, msgs: list[Msg]) -> list[dict]: | |
| """Format input messages into the structure required by the DashScope | |
| OpenAI-compatible API. | |
| To support multi-agent conversations, this formatter processes messages | |
| as follows: | |
| - Prepends an instruction before the first conversation history | |
| section. | |
| - Combines conversation turns into a history section, where each entry | |
| is formatted as ``{name}: {content}``. | |
| - Wraps the conversation history with ``<history>`` and ``</history>`` | |
| tags. | |
| Returns: | |
| `list[dict[str, Any]]`: | |
| A list of dictionaries formatted for the DashScope API. | |
| """ | |
| formatted_msgs = [] | |
| start_index = 0 | |
| if len(msgs) > 0 and msgs[0].role == "system": | |
| formatted_msgs.append( | |
| await self._format_system_message(msgs[0]), | |
| ) | |
| start_index = 1 | |
| is_first_agent_message = True | |
| async for typ, group in self._group_messages(msgs[start_index:]): | |
| match typ: | |
| case "tool_sequence": | |
| formatted_msgs.extend( | |
| await self._format_tool_sequence(group), | |
| ) | |
| case "agent_message": | |
| formatted_msgs.extend( | |
| await self._format_agent_message( | |
| group, | |
| is_first_agent_message, | |
| ), | |
| ) | |
| is_first_agent_message = False | |
| return formatted_msgs | |
| async def _format_tool_sequence( | |
| self, | |
| msgs: list[Msg], | |
| ) -> list[dict[str, Any]]: | |
| """Given a sequence of tool call/result messages, format them into | |
| the required format for the DashScope API.""" | |
| return await DashScopeChatFormatter( | |
| input_types=self.input_types, | |
| ).format(msgs) | |
| async def _format_agent_message( | |
| self, | |
| msgs: list[Msg], | |
| is_first: bool = True, | |
| ) -> list[dict[str, Any]]: | |
| """Given a sequence of messages without tool calls/results, format | |
| them into a user message with conversation history tags.""" | |
| if is_first: | |
| conversation_history_prompt = self.conversation_history_prompt | |
| else: | |
| conversation_history_prompt = "" | |
| formatted_msgs: list[dict] = [] | |
| conversation_blocks: list = [] | |
| accumulated_text = [] | |
| media_blocks: list[dict] = [] | |
| for msg in msgs: | |
| for block in msg.get_content_blocks(): | |
| if isinstance(block, TextBlock): | |
| accumulated_text.append(f"{msg.name}: {block.text}") | |
| elif isinstance(block, DataBlock): | |
| formatted_block = self._format_dashscope_data_block( | |
| block, | |
| ) | |
| if formatted_block is not None: | |
| media_blocks.append(formatted_block) | |
| if accumulated_text: | |
| conversation_blocks.append( | |
| {"text": "\n".join(accumulated_text)}, | |
| ) | |
| if conversation_blocks: | |
| if conversation_blocks[0].get("text"): | |
| conversation_blocks[0]["text"] = ( | |
| conversation_history_prompt | |
| + "<history>\n" | |
| + conversation_blocks[0]["text"] | |
| ) | |
| else: | |
| conversation_blocks.insert( | |
| 0, | |
| {"text": conversation_history_prompt + "<history>\n"}, | |
| ) | |
| if conversation_blocks[-1].get("text"): | |
| conversation_blocks[-1]["text"] += "\n</history>" | |
| else: | |
| conversation_blocks.append({"text": "</history>"}) | |
| conversation_blocks_text = "\n".join( | |
| b.get("text", "") for b in conversation_blocks | |
| ) | |
| content_list: list[dict[str, Any]] = [] | |
| if conversation_blocks_text: | |
| content_list.append( | |
| {"type": "text", "text": conversation_blocks_text}, | |
| ) | |
| content_list.extend(media_blocks) | |
| if content_list: | |
| formatted_msgs.append({"role": "user", "content": content_list}) | |
| return formatted_msgs | |
| async def _format_system_message( | |
| msg: Msg, | |
| ) -> dict[str, Any]: | |
| """Format system message for DashScope API.""" | |
| return { | |
| "role": "system", | |
| "content": msg.get_text_content(), | |
| } | |