# -*- coding: utf-8 -*- """The model response module.""" import base64 from dataclasses import dataclass, field from datetime import datetime from enum import StrEnum from typing import Any, Literal, Self, List from ._model_usage import ChatUsage from .._utils._common import _generate_id from .._utils._mixin import DictMixin from ..message import ( TextBlock, ToolCallBlock, ThinkingBlock, DataBlock, Base64Source, ) from ..types import JSONSerializableObject class FinishedReason(StrEnum): """The finished reason of the model response.""" INTERRUPTED = "interrupted" """The model response is interrupted by the asyncio.CancelledError.""" COMPLETED = "completed" """The model response is completed.""" @dataclass class ChatResponse(DictMixin): """The response of chat models.""" content: List[TextBlock | ToolCallBlock | ThinkingBlock | DataBlock] """The content of the chat response, which can include text blocks, tool use blocks, or thinking blocks.""" is_last: bool """Whether this response is the last response, if `Ture`, the content will be the complete response, otherwise the content is a partial response""" id: str = field(default_factory=_generate_id) """The unique identifier.""" created_at: str = field(default_factory=lambda: datetime.now().isoformat()) """When the response was created""" type: Literal["chat_response"] = field( default_factory=lambda: "chat_response", ) """The type of the response, which is always 'chat_response'.""" usage: ChatUsage | None = field(default_factory=lambda: None) """The usage information of the chat response, if available.""" finished_reason: FinishedReason = field( default=FinishedReason.COMPLETED, ) """The finished reason of the chat response, available when `is_last` is `True`.""" metadata: dict[str, JSONSerializableObject] = field( default_factory=lambda: {}, ) """The metadata of the chat response""" def append_text(self, text: str, block_id: str | None = None) -> Self: """Append text to the current response.""" for block in self.content: if isinstance(block, TextBlock) and ( block_id is None or block_id == block.id ): block.text += text return self # Append a new block assert isinstance(self.content, list) self.content.append( TextBlock(text=text, id=block_id or _generate_id()), ) return self def append_thinking( self, thinking: str, block_id: str | None = None, **extra_fields: Any, ) -> Self: """Append thinking to the current response. Args: thinking (`str`): The thinking content to append. block_id (`str | None`, defaults to `None`): The id of the ``ThinkingBlock`` to accumulate into. If no matching block exists in ``content``, a new one is appended. **extra_fields (`Any`): Additional provider-specific fields to attach to the ``ThinkingBlock`` (e.g. Anthropic's ``signature``, OpenAI Responses API's ``reasoning_item_id``). Only non-``None`` values are applied. """ for block in self.content: if isinstance(block, ThinkingBlock) and ( block_id is None or block_id == block.id ): block.thinking += thinking for key, value in extra_fields.items(): if value is not None: setattr(block, key, value) return self assert isinstance(self.content, list) block = ThinkingBlock(thinking=thinking, id=block_id or _generate_id()) for key, value in extra_fields.items(): if value is not None: setattr(block, key, value) self.content.append(block) return self def append_tool_call( self, block_id: str, name: str, input: str, # pylint: disable=redefined-builtin **extra_fields: Any, ) -> Self: """Append tool call to the current response by tool call block ID. Args: block_id (`str`): The id of the ``ToolCallBlock`` to accumulate into. If no matching block exists in ``content``, a new one is appended. name (`str`): The name of the tool being called. input (`str`): The incremental JSON string arguments to append. **extra_fields (`Any`): Additional provider-specific fields to attach to the ``ToolCallBlock`` (e.g. OpenAI Responses API's ``call_id``). Only non-``None`` values are applied. """ for block in self.content: if isinstance(block, ToolCallBlock) and block.id == block_id: block.input += input for key, value in extra_fields.items(): if value is not None: setattr(block, key, value) return self block = ToolCallBlock( id=block_id, name=name, input=input, ) for key, value in extra_fields.items(): if value is not None: setattr(block, key, value) assert isinstance(self.content, list) self.content.append(block) return self def append_data_block( self, block_id: str, data: bytes, media_type: str, name: str | None = None, ) -> Self: """Append raw media bytes to the ``DataBlock`` with the given id. The accumulated bytes are stored base64-encoded in ``DataBlock.source.data`` (via a :class:`Base64Source`). Callers must pass the *incremental* raw media bytes only — the method takes care of base64 (de)coding internally, so consumers never have to worry about base64 padding (``=``) corrupting concatenation. .. note:: Byte-level delta accumulation only has a well-defined semantics for streaming media where chunks can be safely concatenated (currently ``audio/*``). For non-streamable media types (e.g. ``image/*``, ``video/*``) each ``DataBlock`` should be treated as a complete file and passed atomically rather than accumulated through this method. Args: block_id (`str`): The id of the ``DataBlock`` to accumulate into. If no matching block exists in ``content``, a new one is appended. data (`bytes`): The incremental raw media bytes to append. media_type (`str`): The IANA media type of the raw bytes (e.g. ``audio/pcm``, ``audio/wav``). Used both to tag a newly-created block and to guard against accidentally mixing bytes from different media types under the same id. name (`str | None`, defaults to `None`): The optional ``name`` field used when a new ``DataBlock`` needs to be created. Ignored when an existing block with ``block_id`` is found. Returns: `Self`: The current ``ChatResponse`` instance for chaining. """ for block in self.content: if ( isinstance(block, DataBlock) and block.id == block_id and isinstance(block.source, Base64Source) and block.source.media_type == media_type ): old_bytes = ( base64.b64decode(block.source.data) if block.source.data else b"" ) block.source.data = base64.b64encode( old_bytes + data, ).decode("ascii") return self self.content.append( DataBlock( id=block_id, source=Base64Source( data=base64.b64encode(data).decode("ascii"), media_type=media_type, ), name=name, ), ) return self def append_chat_response(self, chat_response: Self) -> Self: """Append chat response to the current response.""" # Append content new_block_dict = {_.id: _ for _ in chat_response.content} for block in self.content: if block.id in new_block_dict: delta_block = new_block_dict.pop(block.id) # Append data according to the block type if isinstance(block, ThinkingBlock): block.thinking += delta_block.thinking # Provider-specific extra fields (e.g. Anthropic's # ``signature``, OpenAI Responses API's # ``reasoning_item_id``) are carried on the delta # ``ThinkingBlock`` via pydantic's ``extra="allow"``. # Copy any non-``None`` extras onto the accumulator. for key, value in (delta_block.model_extra or {}).items(): if value is not None: setattr(block, key, value) elif isinstance(block, TextBlock): block.text += delta_block.text elif isinstance(block, ToolCallBlock): block.input += delta_block.input # Provider-specific extras (e.g. OpenAI Responses # API's ``call_id``) may be attached on the delta # block via pydantic's ``extra="allow"``. for key, value in (delta_block.model_extra or {}).items(): if value is not None: setattr(block, key, value) elif isinstance(block, DataBlock): # Only ``audio/*`` is treated as a streamable delta: # callers accumulate raw media bytes across chunks and # the concatenated result is a well-defined stream. # For non-audio media types (``image/*``, ``video/*``, # ...) each ``DataBlock`` is a complete standalone # asset — byte concatenation is meaningless — so we # overwrite in place with the latest delta instead. if not ( isinstance(block.source, Base64Source) and isinstance(delta_block.source, Base64Source) and block.source.media_type == delta_block.source.media_type ): # Source shape / media type mismatch: replace the # whole block to avoid mixing incompatible data. block.source = delta_block.source elif block.source.media_type.startswith("audio/"): old_bytes = ( base64.b64decode(block.source.data) if block.source.data else b"" ) delta_bytes = ( base64.b64decode(delta_block.source.data) if delta_block.source.data else b"" ) block.source.data = base64.b64encode( old_bytes + delta_bytes, ).decode("ascii") else: # Non-streamable media: latest delta wins. block.source.data = delta_block.source.data if new_block_dict: # Attach new blocks to the content. self.content.extend( block.model_copy(deep=True) for block in new_block_dict.values() ) # Override the chat usage if chat_response.usage: self.usage = chat_response.usage return self @dataclass class StructuredResponse: """The structured response of chat models.""" content: dict """The structured output of the model.""" id: str = field(default_factory=_generate_id) """The unique identifier.""" created_at: str = field(default_factory=lambda: datetime.now().isoformat()) """When the response was created""" type: Literal["structured_response"] = field( default_factory=lambda: "structured_response", ) """The type of the response, which is always 'structured_response'.""" usage: ChatUsage | None = field(default_factory=lambda: None) """The usage information of the chat response, if available.""" metadata: dict[str, JSONSerializableObject] = field( default_factory=lambda: {}, ) """The metadata of the chat response""" finished_reason: FinishedReason = field( default=FinishedReason.COMPLETED, ) """The finished reason of the structured response."""