From 0d66cc26385fe5239b31c8dcd637cf39f3a064fe Mon Sep 17 00:00:00 2001 From: Chester Curme Date: Tue, 8 Jul 2025 14:56:53 -0400 Subject: [PATCH] carry over changes --- .../language_models/chat_models.py | 12 + libs/core/langchain_core/messages/__init__.py | 27 ++ libs/core/langchain_core/messages/base.py | 3 +- .../langchain_core/messages/content_blocks.py | 109 +++++ .../language_models/chat_models/test_cache.py | 5 +- .../langchain_openai/chat_models/_compat.py | 315 ++++++++++++- .../langchain_openai/chat_models/base.py | 97 +++- .../cassettes/test_function_calling.yaml.gz | Bin 0 -> 7912 bytes .../test_stream_reasoning_summary.yaml.gz | Bin 18429 -> 17751 bytes .../chat_models/test_responses_api.py | 57 ++- .../tests/unit_tests/chat_models/test_base.py | 421 +++++++++++++++++- .../chat_models/test_responses_stream.py | 122 +++-- 12 files changed, 1095 insertions(+), 73 deletions(-) create mode 100644 libs/partners/openai/tests/cassettes/test_function_calling.yaml.gz diff --git a/libs/core/langchain_core/language_models/chat_models.py b/libs/core/langchain_core/language_models/chat_models.py index db02058400c..3f264e854d5 100644 --- a/libs/core/langchain_core/language_models/chat_models.py +++ b/libs/core/langchain_core/language_models/chat_models.py @@ -311,6 +311,18 @@ class BaseChatModel(BaseLanguageModel[BaseMessage], ABC): does not properly support streaming. """ + output_version: str = "v0" + """Version of AIMessage output format to use. + + This field is used to roll-out new output formats for chat model AIMessages + in a backwards-compatible way. + + All chat models currently support the default of ``"v0"``. Chat model subclasses + can override with (customizable) supported values. + + .. versionadded:: 0.3.68 + """ + @model_validator(mode="before") @classmethod def raise_deprecation(cls, values: dict) -> Any: diff --git a/libs/core/langchain_core/messages/__init__.py b/libs/core/langchain_core/messages/__init__.py index fe87e964af2..5569a559bc8 100644 --- a/libs/core/langchain_core/messages/__init__.py +++ b/libs/core/langchain_core/messages/__init__.py @@ -33,6 +33,15 @@ if TYPE_CHECKING: ) from langchain_core.messages.chat import ChatMessage, ChatMessageChunk from langchain_core.messages.content_blocks import ( + Base64ContentBlock, + ContentBlock, + DocumentCitation, + NonStandardAnnotation, + NonStandardContentBlock, + ReasoningContentBlock, + TextContentBlock, + ToolCallContentBlock, + UrlCitation, convert_to_openai_data_block, convert_to_openai_image_block, is_data_content_block, @@ -66,23 +75,32 @@ __all__ = ( "AIMessage", "AIMessageChunk", "AnyMessage", + "Base64ContentBlock", "BaseMessage", "BaseMessageChunk", "ChatMessage", "ChatMessageChunk", + "ContentBlock", + "DocumentCitation", "FunctionMessage", "FunctionMessageChunk", "HumanMessage", "HumanMessageChunk", "InvalidToolCall", "MessageLikeRepresentation", + "NonStandardAnnotation", + "NonStandardContentBlock", + "ReasoningContentBlock", "RemoveMessage", "SystemMessage", "SystemMessageChunk", + "TextContentBlock", "ToolCall", "ToolCallChunk", + "ToolCallContentBlock", "ToolMessage", "ToolMessageChunk", + "UrlCitation", "_message_from_dict", "convert_to_messages", "convert_to_openai_data_block", @@ -103,25 +121,34 @@ __all__ = ( _dynamic_imports = { "AIMessage": "ai", "AIMessageChunk": "ai", + "Base64ContentBlock": "content_blocks", "BaseMessage": "base", "BaseMessageChunk": "base", "merge_content": "base", "message_to_dict": "base", "messages_to_dict": "base", + "ContentBlock": "content_blocks", "ChatMessage": "chat", "ChatMessageChunk": "chat", + "DocumentCitation": "content_blocks", "FunctionMessage": "function", "FunctionMessageChunk": "function", "HumanMessage": "human", "HumanMessageChunk": "human", + "NonStandardAnnotation": "content_blocks", + "NonStandardContentBlock": "content_blocks", + "ReasoningContentBlock": "content_blocks", "RemoveMessage": "modifier", "SystemMessage": "system", "SystemMessageChunk": "system", "InvalidToolCall": "tool", + "TextContentBlock": "content_blocks", "ToolCall": "tool", "ToolCallChunk": "tool", + "ToolCallContentBlock": "content_blocks", "ToolMessage": "tool", "ToolMessageChunk": "tool", + "UrlCitation": "content_blocks", "AnyMessage": "utils", "MessageLikeRepresentation": "utils", "_message_from_dict": "utils", diff --git a/libs/core/langchain_core/messages/base.py b/libs/core/langchain_core/messages/base.py index ba976286b75..3974fb00476 100644 --- a/libs/core/langchain_core/messages/base.py +++ b/libs/core/langchain_core/messages/base.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Optional, Union, cast from pydantic import ConfigDict, Field from langchain_core.load.serializable import Serializable +from langchain_core.messages import ContentBlock from langchain_core.utils import get_bolded_text from langchain_core.utils._merge import merge_dicts, merge_lists from langchain_core.utils.interactive_env import is_interactive_env @@ -23,7 +24,7 @@ class BaseMessage(Serializable): Messages are the inputs and outputs of ChatModels. """ - content: Union[str, list[Union[str, dict]]] + content: Union[str, list[Union[str, ContentBlock, dict]]] """The string contents of the message.""" additional_kwargs: dict = Field(default_factory=dict) diff --git a/libs/core/langchain_core/messages/content_blocks.py b/libs/core/langchain_core/messages/content_blocks.py index 83a66fb123a..27f21f5b3a4 100644 --- a/libs/core/langchain_core/messages/content_blocks.py +++ b/libs/core/langchain_core/messages/content_blocks.py @@ -7,6 +7,93 @@ from pydantic import TypeAdapter, ValidationError from typing_extensions import NotRequired, TypedDict +# Text and annotations +class UrlCitation(TypedDict, total=False): + """Citation from a URL.""" + + type: Literal["url_citation"] + + url: str + """Source URL.""" + + title: NotRequired[str] + """Source title.""" + + cited_text: NotRequired[str] + """Text from the source that is being cited.""" + + start_index: NotRequired[int] + """Start index of the response text for which the annotation applies.""" + + end_index: NotRequired[int] + """End index of the response text for which the annotation applies.""" + + +class DocumentCitation(TypedDict, total=False): + """Annotation for data from a document.""" + + type: Literal["document_citation"] + + title: NotRequired[str] + """Source title.""" + + cited_text: NotRequired[str] + """Text from the source that is being cited.""" + + start_index: NotRequired[int] + """Start index of the response text for which the annotation applies.""" + + end_index: NotRequired[int] + """End index of the response text for which the annotation applies.""" + + +class NonStandardAnnotation(TypedDict, total=False): + """Provider-specific annotation format.""" + + type: Literal["non_standard_annotation"] + """Type of the content block.""" + value: dict[str, Any] + """Provider-specific annotation data.""" + + +class TextContentBlock(TypedDict, total=False): + """Content block for text output.""" + + type: Literal["text"] + """Type of the content block.""" + text: str + """Block text.""" + annotations: NotRequired[ + list[Union[UrlCitation, DocumentCitation, NonStandardAnnotation]] + ] + """Citations and other annotations.""" + + +# Tool calls +class ToolCallContentBlock(TypedDict, total=False): + """Content block for tool calls. + + These are references to a :class:`~langchain_core.messages.tool.ToolCall` in the + message's ``tool_calls`` attribute. + """ + + type: Literal["tool_call"] + """Type of the content block.""" + id: str + """Tool call ID.""" + + +# Reasoning +class ReasoningContentBlock(TypedDict, total=False): + """Content block for reasoning output.""" + + type: Literal["reasoning"] + """Type of the content block.""" + reasoning: NotRequired[str] + """Reasoning text.""" + + +# Multi-modal class BaseDataContentBlock(TypedDict, total=False): """Base class for data content blocks.""" @@ -68,6 +155,28 @@ DataContentBlock = Union[ _DataContentBlockAdapter: TypeAdapter[DataContentBlock] = TypeAdapter(DataContentBlock) +# Non-standard +class NonStandardContentBlock(TypedDict, total=False): + """Content block provider-specific data. + + This block contains data for which there is not yet a standard type. + """ + + type: Literal["non_standard"] + """Type of the content block.""" + value: dict[str, Any] + """Provider-specific data.""" + + +ContentBlock = Union[ + TextContentBlock, + ToolCallContentBlock, + ReasoningContentBlock, + DataContentBlock, + NonStandardContentBlock, +] + + def is_data_content_block( content_block: dict, ) -> bool: diff --git a/libs/core/tests/unit_tests/language_models/chat_models/test_cache.py b/libs/core/tests/unit_tests/language_models/chat_models/test_cache.py index 1cceb0a146b..d611fc36c4e 100644 --- a/libs/core/tests/unit_tests/language_models/chat_models/test_cache.py +++ b/libs/core/tests/unit_tests/language_models/chat_models/test_cache.py @@ -300,8 +300,9 @@ def test_llm_representation_for_serializable() -> None: assert chat._get_llm_string() == ( '{"id": ["tests", "unit_tests", "language_models", "chat_models", ' '"test_cache", "CustomChat"], "kwargs": {"messages": {"id": ' - '["builtins", "list_iterator"], "lc": 1, "type": "not_implemented"}}, "lc": ' - '1, "name": "CustomChat", "type": "constructor"}---[(\'stop\', None)]' + '["builtins", "list_iterator"], "lc": 1, "type": "not_implemented"}, ' + '"output_version": "v0"}, "lc": 1, "name": "CustomChat", "type": ' + "\"constructor\"}---[('stop', None)]" ) diff --git a/libs/partners/openai/langchain_openai/chat_models/_compat.py b/libs/partners/openai/langchain_openai/chat_models/_compat.py index e1297308e72..2402fb8063d 100644 --- a/libs/partners/openai/langchain_openai/chat_models/_compat.py +++ b/libs/partners/openai/langchain_openai/chat_models/_compat.py @@ -1,7 +1,10 @@ """ -This module converts between AIMessage output formats for the Responses API. +This module converts between AIMessage output formats, which are governed by the +``output_version`` attribute on ChatOpenAI. Supported values are ``"v0"``, +``"responses/v1"``, and ``"v1"``. -ChatOpenAI v0.3 stores reasoning and tool outputs in AIMessage.additional_kwargs: +``"v0"`` corresponds to the format as of ChatOpenAI v0.3. For the Responses API, it +stores reasoning and tool outputs in AIMessage.additional_kwargs: .. code-block:: python @@ -28,8 +31,9 @@ ChatOpenAI v0.3 stores reasoning and tool outputs in AIMessage.additional_kwargs id="msg_123", ) -To retain information about response item sequencing (and to accommodate multiple -reasoning items), ChatOpenAI now stores these items in the content sequence: +``"responses/v1"`` is only applicable to the Responses API. It retains information +about response item sequencing and accommodates multiple reasoning items by +representing these items in the content sequence: .. code-block:: python @@ -56,18 +60,39 @@ reasoning items), ChatOpenAI now stores these items in the content sequence: There are other, small improvements as well-- e.g., we store message IDs on text content blocks, rather than on the AIMessage.id, which now stores the response ID. +``"v1"`` represents LangChain's cross-provider standard format. + For backwards compatibility, this module provides functions to convert between the -old and new formats. The functions are used internally by ChatOpenAI. +formats. The functions are used internally by ChatOpenAI. """ # noqa: E501 import json -from typing import Union +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Union, cast -from langchain_core.messages import AIMessage +from langchain_core.messages import ( + AIMessage, + AIMessageChunk, + DocumentCitation, + NonStandardAnnotation, + ReasoningContentBlock, + UrlCitation, + is_data_content_block, +) + +if TYPE_CHECKING: + from langchain_core.messages import ( + Base64ContentBlock, + NonStandardContentBlock, + ReasoningContentBlock, + TextContentBlock, + ToolCallContentBlock, + ) _FUNCTION_CALL_IDS_MAP_KEY = "__openai_function_call_ids__" +# v0.3 / Responses def _convert_to_v03_ai_message( message: AIMessage, has_reasoning: bool = False ) -> AIMessage: @@ -252,3 +277,279 @@ def _convert_from_v03_ai_message(message: AIMessage) -> AIMessage: }, deep=False, ) + + +# v1 / Chat Completions +def _convert_to_v1_from_chat_completions(message: AIMessage) -> AIMessage: + """Mutate a Chat Completions message to v1 format.""" + if isinstance(message.content, str): + if message.content: + block: TextContentBlock = {"type": "text", "text": message.content} + message.content = [block] + else: + message.content = [] + + for tool_call in message.tool_calls: + if id_ := tool_call.get("id"): + tool_callblock: ToolCallContentBlock = {"type": "tool_call", "id": id_} + message.content.append(tool_callblock) + + if "tool_calls" in message.additional_kwargs: + _ = message.additional_kwargs.pop("tool_calls") + + if "token_usage" in message.response_metadata: + _ = message.response_metadata.pop("token_usage") + + return message + + +def _convert_to_v1_from_chat_completions_chunk(chunk: AIMessageChunk) -> AIMessageChunk: + result = _convert_to_v1_from_chat_completions(cast(AIMessage, chunk)) + return cast(AIMessageChunk, result) + + +def _convert_from_v1_to_chat_completions(message: AIMessage) -> AIMessage: + """Convert a v1 message to the Chat Completions format.""" + if isinstance(message.content, list): + new_content: list = [] + for block in message.content: + if isinstance(block, dict): + block_type = block.get("type") + if block_type == "text": + # Strip annotations + new_content.append({"type": "text", "text": block["text"]}) + elif block_type in ("reasoning", "tool_call"): + pass + else: + new_content.append(block) + else: + new_content.append(block) + return message.model_copy(update={"content": new_content}) + + return message + + +# v1 / Responses +def _convert_annotation_to_v1( + annotation: dict[str, Any], +) -> Union[UrlCitation, DocumentCitation, NonStandardAnnotation]: + annotation_type = annotation.get("type") + + if annotation_type == "url_citation": + new_annotation: UrlCitation = {"type": "url_citation", "url": annotation["url"]} + for field in ("title", "start_index", "end_index"): + if field in annotation: + new_annotation[field] = annotation[field] + return new_annotation + + elif annotation_type == "file_citation": + new_annotation: DocumentCitation = {"type": "document_citation"} + if "filename" in annotation: + new_annotation["title"] = annotation["filename"] + for field in ("file_id", "index"): # OpenAI-specific + if field in annotation: + new_annotation[field] = annotation[field] + return new_annotation + + # TODO: standardise container_file_citation? + else: + new_annotation: NonStandardAnnotation = { + "type": "non_standard_annotation", + "value": annotation, + } + return new_annotation + + +def _explode_reasoning(block: dict[str, Any]) -> Iterable[ReasoningContentBlock]: + if block.get("type") != "reasoning" or "summary" not in block: + yield block + return + + if not block["summary"]: + _ = block.pop("summary", None) + yield block + return + + # Common part for every exploded line, except 'summary' + common = {k: v for k, v in block.items() if k != "summary"} + + # Optional keys that must appear only in the first exploded item + first_only = { + k: common.pop(k) for k in ("encrypted_content", "status") if k in common + } + + for idx, part in enumerate(block["summary"]): + new_block = dict(common) + new_block["reasoning"] = part.get("text", "") + if idx == 0: + new_block.update(first_only) + yield cast(ReasoningContentBlock, new_block) + + +def _convert_to_v1_from_responses(message: AIMessage) -> AIMessage: + """Mutate a Responses message to v1 format.""" + if not isinstance(message.content, list): + return message + + def _iter_blocks() -> Iterable[dict[str, Any]]: + for block in message.content: + block_type = block.get("type") + + if block_type == "text": + if "annotations" in block: + block["annotations"] = [ + _convert_annotation_to_v1(a) for a in block["annotations"] + ] + yield block + + elif block_type == "reasoning": + yield from _explode_reasoning(block) + + elif block_type == "image_generation_call" and ( + result := block.get("result") + ): + new_block: Base64ContentBlock = { + "type": "image", + "source_type": "base64", + "data": result, + } + for extra_key in ("id", "status"): + if extra_key in block: + new_block[extra_key] = block[extra_key] + yield new_block + + elif block_type == "function_call": + new_block: ToolCallContentBlock = { + "type": "tool_call", + "id": block["call_id"], + } + if "id" in block: + new_block["item_id"] = block["id"] + for extra_key in ("arguments", "name"): + if extra_key in block: + new_block[extra_key] = block[extra_key] + yield new_block + + else: + new_block: NonStandardContentBlock = { + "type": "non_standard", + "value": block, + } + if "index" in new_block["value"]: + new_block["index"] = new_block["value"].pop("index") + yield new_block + + # Replace the list with the fully converted one + message.content = list(_iter_blocks()) + + return message + + +def _convert_annotation_from_v1(annotation: dict[str, Any]) -> dict[str, Any]: + annotation_type = annotation.get("type") + + if annotation_type == "document_citation": + new_ann: dict[str, Any] = {"type": "file_citation"} + + if "title" in annotation: + new_ann["filename"] = annotation["title"] + + for fld in ("file_id", "index"): + if fld in annotation: + new_ann[fld] = annotation[fld] + + return new_ann + + elif annotation_type == "non_standard_annotation": + return annotation["value"] + + else: + return dict(annotation) + + +def _implode_reasoning_blocks(blocks: list[dict[str, Any]]) -> Iterable[dict[str, Any]]: + i = 0 + n = len(blocks) + + while i < n: + block = blocks[i] + + # Ordinary block – just yield a shallow copy + if block.get("type") != "reasoning" or "reasoning" not in block: + yield dict(block) + i += 1 + continue + + summary: list[dict[str, str]] = [ + {"type": "summary_text", "text": block.get("reasoning", "")} + ] + # 'common' is every field except the exploded 'reasoning' + common = {k: v for k, v in block.items() if k != "reasoning"} + + i += 1 + while i < n: + next_ = blocks[i] + if next_.get("type") == "reasoning" and "reasoning" in next_: + summary.append( + {"type": "summary_text", "text": next_.get("reasoning", "")} + ) + i += 1 + else: + break + + merged = dict(common) + merged["summary"] = summary + yield merged + + +def _convert_from_v1_to_responses(message: AIMessage) -> AIMessage: + if not isinstance(message.content, list): + return message + + new_content: list = [] + for block in message.content: + if isinstance(block, dict): + block_type = block.get("type") + if block_type == "text" and "annotations" in block: + # Need a copy because we’re changing the annotations list + new_block = dict(block) + new_block["annotations"] = [ + _convert_annotation_from_v1(a) for a in block["annotations"] + ] + new_content.append(new_block) + elif block_type == "tool_call": + new_block = {"type": "function_call", "call_id": block["id"]} + if "item_id" in block: + new_block["id"] = block["item_id"] + if "name" in block and "arguments" in block: + new_block["name"] = block["name"] + new_block["arguments"] = block["arguments"] + else: + tool_call = next( + call for call in message.tool_calls if call["id"] == block["id"] + ) + if "name" not in block: + new_block["name"] = tool_call["name"] + if "arguments" not in block: + new_block["arguments"] = json.dumps(tool_call["args"]) + new_content.append(new_block) + elif ( + is_data_content_block(block) + and block["type"] == "image" + and block["source_type"] == "base64" + ): + new_block = {"type": "image_generation_call", "result": block["data"]} + for extra_key in ("id", "status"): + if extra_key in block: + new_block[extra_key] = block[extra_key] + new_content.append(new_block) + elif block_type == "non_standard" and "value" in block: + new_content.append(block["value"]) + else: + new_content.append(block) + else: + new_content.append(block) + + new_content = list(_implode_reasoning_blocks(new_content)) + + return message.model_copy(update={"content": new_content}) diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index b5dea2b0f87..cb742b80bf1 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -108,7 +108,12 @@ from langchain_openai.chat_models._client_utils import ( ) from langchain_openai.chat_models._compat import ( _convert_from_v03_ai_message, + _convert_from_v1_to_chat_completions, + _convert_from_v1_to_responses, _convert_to_v03_ai_message, + _convert_to_v1_from_chat_completions, + _convert_to_v1_from_chat_completions_chunk, + _convert_to_v1_from_responses, ) if TYPE_CHECKING: @@ -650,7 +655,7 @@ class BaseChatOpenAI(BaseChatModel): .. versionadded:: 0.3.9 """ - output_version: Literal["v0", "responses/v1"] = "v0" + output_version: str = "v0" """Version of AIMessage output format to use. This field is used to roll-out new output formats for chat model AIMessages @@ -661,9 +666,9 @@ class BaseChatOpenAI(BaseChatModel): - ``"v0"``: AIMessage format as of langchain-openai 0.3.x. - ``"responses/v1"``: Formats Responses API output items into AIMessage content blocks. + - ``"v1"``: v1 of LangChain cross-provider standard. - Currently only impacts the Responses API. ``output_version="responses/v1"`` is - recommended. + ``output_version="v1"`` is recommended. .. versionadded:: 0.3.25 @@ -850,6 +855,10 @@ class BaseChatOpenAI(BaseChatModel): message=default_chunk_class(content="", usage_metadata=usage_metadata), generation_info=base_generation_info, ) + if self.output_version == "v1": + generation_chunk.message = _convert_to_v1_from_chat_completions_chunk( + cast(AIMessageChunk, generation_chunk.message) + ) return generation_chunk choice = choices[0] @@ -877,6 +886,20 @@ class BaseChatOpenAI(BaseChatModel): if usage_metadata and isinstance(message_chunk, AIMessageChunk): message_chunk.usage_metadata = usage_metadata + if self.output_version == "v1": + message_chunk = cast(AIMessageChunk, message_chunk) + # Convert to v1 format + if isinstance(message_chunk.content, str): + message_chunk = _convert_to_v1_from_chat_completions_chunk( + message_chunk + ) + if message_chunk.content: + message_chunk.content[0]["index"] = 0 # type: ignore[index] + else: + message_chunk = _convert_to_v1_from_chat_completions_chunk( + message_chunk + ) + generation_chunk = ChatGenerationChunk( message=message_chunk, generation_info=generation_info or None ) @@ -1169,7 +1192,12 @@ class BaseChatOpenAI(BaseChatModel): else: payload = _construct_responses_api_payload(messages, payload) else: - payload["messages"] = [_convert_message_to_dict(m) for m in messages] + payload["messages"] = [ + _convert_message_to_dict(_convert_from_v1_to_chat_completions(m)) + if isinstance(m, AIMessage) + else _convert_message_to_dict(m) + for m in messages + ] return payload def _create_chat_result( @@ -1235,6 +1263,11 @@ class BaseChatOpenAI(BaseChatModel): if hasattr(message, "refusal"): generations[0].message.additional_kwargs["refusal"] = message.refusal + if self.output_version == "v1": + _ = llm_output.pop("token_usage", None) + generations[0].message = _convert_to_v1_from_chat_completions( + cast(AIMessage, generations[0].message) + ) return ChatResult(generations=generations, llm_output=llm_output) async def _astream( @@ -3475,6 +3508,7 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list: for lc_msg in messages: if isinstance(lc_msg, AIMessage): lc_msg = _convert_from_v03_ai_message(lc_msg) + lc_msg = _convert_from_v1_to_responses(lc_msg) msg = _convert_message_to_dict(lc_msg) # "name" parameter unsupported if "name" in msg: @@ -3618,7 +3652,7 @@ def _construct_lc_result_from_responses_api( response: Response, schema: Optional[type[_BM]] = None, metadata: Optional[dict] = None, - output_version: Literal["v0", "responses/v1"] = "v0", + output_version: str = "v0", ) -> ChatResult: """Construct ChatResponse from OpenAI Response API response.""" if response.error: @@ -3757,6 +3791,8 @@ def _construct_lc_result_from_responses_api( ) if output_version == "v0": message = _convert_to_v03_ai_message(message) + elif output_version == "v1": + message = _convert_to_v1_from_responses(message) else: pass return ChatResult(generations=[ChatGeneration(message=message)]) @@ -3770,7 +3806,7 @@ def _convert_responses_chunk_to_generation_chunk( schema: Optional[type[_BM]] = None, metadata: Optional[dict] = None, has_reasoning: bool = False, - output_version: Literal["v0", "responses/v1"] = "v0", + output_version: str = "v0", ) -> tuple[int, int, int, Optional[ChatGenerationChunk]]: def _advance(output_idx: int, sub_idx: Optional[int] = None) -> None: """Advance indexes tracked during streaming. @@ -3837,7 +3873,17 @@ def _convert_responses_chunk_to_generation_chunk( annotation = chunk.annotation.model_dump(exclude_none=True, mode="json") content.append({"annotations": [annotation], "index": current_index}) elif chunk.type == "response.output_text.done": - content.append({"id": chunk.item_id, "index": current_index}) + if output_version == "v1": + content.append( + { + "type": "text", + "text": "", + "id": chunk.item_id, + "index": current_index, + } + ) + else: + content.append({"id": chunk.item_id, "index": current_index}) elif chunk.type == "response.created": id = chunk.response.id response_metadata["id"] = chunk.response.id # Backwards compatibility @@ -3913,21 +3959,34 @@ def _convert_responses_chunk_to_generation_chunk( content.append({"type": "refusal", "refusal": chunk.refusal}) elif chunk.type == "response.output_item.added" and chunk.item.type == "reasoning": _advance(chunk.output_index) + current_sub_index = 0 reasoning = chunk.item.model_dump(exclude_none=True, mode="json") reasoning["index"] = current_index content.append(reasoning) elif chunk.type == "response.reasoning_summary_part.added": - _advance(chunk.output_index) - content.append( - { - # langchain-core uses the `index` key to aggregate text blocks. - "summary": [ - {"index": chunk.summary_index, "type": "summary_text", "text": ""} - ], - "index": current_index, - "type": "reasoning", - } - ) + if output_version in ("v0", "responses/v1"): + _advance(chunk.output_index) + content.append( + { + # langchain-core uses the `index` key to aggregate text blocks. + "summary": [ + { + "index": chunk.summary_index, + "type": "summary_text", + "text": "", + } + ], + "index": current_index, + "type": "reasoning", + } + ) + else: + block = {"type": "reasoning", "reasoning": ""} + if chunk.summary_index > 0: + _advance(chunk.output_index, chunk.summary_index) + block["id"] = chunk.item_id + block["index"] = current_index + content.append(block) elif chunk.type == "response.image_generation_call.partial_image": # Partial images are not supported yet. pass @@ -3962,6 +4021,8 @@ def _convert_responses_chunk_to_generation_chunk( AIMessageChunk, _convert_to_v03_ai_message(message, has_reasoning=has_reasoning), ) + elif output_version == "v1": + message = _convert_to_v1_from_responses(message) else: pass return ( diff --git a/libs/partners/openai/tests/cassettes/test_function_calling.yaml.gz b/libs/partners/openai/tests/cassettes/test_function_calling.yaml.gz new file mode 100644 index 0000000000000000000000000000000000000000..197a8402cf6ebaf57e31e7d5600363e77502e1c7 GIT binary patch literal 7912 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 SSAKVM^8WyoQmuSQZvX)1?X4^T literal 0 HcmV?d00001 diff --git a/libs/partners/openai/tests/cassettes/test_stream_reasoning_summary.yaml.gz b/libs/partners/openai/tests/cassettes/test_stream_reasoning_summary.yaml.gz index ac89d7580cd328633e2fc60276d6d97a39951bff..63700d1ceab0a700b92e707aa3639ca3d61b8dcd 100644 GIT binary patch literal 17751 zcmYg%V{|CNvTkhKwr$(Cv18k|lO5aUj&0kvZS&=v`|euzS8LXE^>j^F)mLBRM?nA> zQd*k>{apEIH`GhiKYx9r@~R`xA=<$dJU1dUqE9f_fX12~$;}p1lyxl@V|iIqyp~V3iYcyw_6w|oRaN?HnH{&#rV;MEtRpi9 z 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 b^EWo{+gsMh$J5t--17W?!wOHFa-RYKQ%M!< diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py b/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py index 0e23d0e3f06..6b4c9d093fa 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_responses_api.py @@ -52,9 +52,11 @@ def _check_response(response: Optional[BaseMessage]) -> None: assert response.response_metadata["service_tier"] +@pytest.mark.default_cassette("test_web_search.yaml.gz") @pytest.mark.vcr -def test_web_search() -> None: - llm = ChatOpenAI(model=MODEL_NAME, output_version="responses/v1") +@pytest.mark.parametrize("output_version", ["responses/v1", "v1"]) +def test_web_search(output_version: Literal["v0", "responses/v1", "v1"]) -> None: + llm = ChatOpenAI(model=MODEL_NAME, output_version=output_version) first_response = llm.invoke( "What was a positive news story from today?", tools=[{"type": "web_search_preview"}], @@ -141,13 +143,15 @@ async def test_web_search_async() -> None: assert tool_output["type"] == "web_search_call" -@pytest.mark.flaky(retries=3, delay=1) -def test_function_calling() -> None: +@pytest.mark.default_cassette("test_function_calling.yaml.gz") +@pytest.mark.vcr +@pytest.mark.parametrize("output_version", ["v0", "responses/v1", "v1"]) +def test_function_calling(output_version: Literal["v0", "responses/v1", "v1"]) -> None: def multiply(x: int, y: int) -> int: """return x * y""" return x * y - llm = ChatOpenAI(model=MODEL_NAME) + llm = ChatOpenAI(model=MODEL_NAME, output_version=output_version) bound_llm = llm.bind_tools([multiply, {"type": "web_search_preview"}]) ai_msg = cast(AIMessage, bound_llm.invoke("whats 5 * 4")) assert len(ai_msg.tool_calls) == 1 @@ -297,8 +301,8 @@ def test_function_calling_and_structured_output() -> None: @pytest.mark.default_cassette("test_reasoning.yaml.gz") @pytest.mark.vcr -@pytest.mark.parametrize("output_version", ["v0", "responses/v1"]) -def test_reasoning(output_version: Literal["v0", "responses/v1"]) -> None: +@pytest.mark.parametrize("output_version", ["v0", "responses/v1", "v1"]) +def test_reasoning(output_version: Literal["v0", "responses/v1", "v1"]) -> None: llm = ChatOpenAI( model="o4-mini", use_responses_api=True, output_version=output_version ) @@ -376,9 +380,9 @@ def test_file_search() -> None: @pytest.mark.default_cassette("test_stream_reasoning_summary.yaml.gz") @pytest.mark.vcr -@pytest.mark.parametrize("output_version", ["v0", "responses/v1"]) +@pytest.mark.parametrize("output_version", ["v0", "responses/v1", "v1"]) def test_stream_reasoning_summary( - output_version: Literal["v0", "responses/v1"], + output_version: Literal["v0", "responses/v1", "v1"], ) -> None: llm = ChatOpenAI( model="o4-mini", @@ -398,20 +402,39 @@ def test_stream_reasoning_summary( if output_version == "v0": reasoning = response_1.additional_kwargs["reasoning"] assert set(reasoning.keys()) == {"id", "type", "summary"} - else: + summary = reasoning["summary"] + assert isinstance(summary, list) + for block in summary: + assert isinstance(block, dict) + assert isinstance(block["type"], str) + assert isinstance(block["text"], str) + assert block["text"] + elif output_version == "responses/v1": reasoning = next( block for block in response_1.content if block["type"] == "reasoning" # type: ignore[index] ) assert set(reasoning.keys()) == {"id", "type", "summary", "index"} - summary = reasoning["summary"] - assert isinstance(summary, list) - for block in summary: - assert isinstance(block, dict) - assert isinstance(block["type"], str) - assert isinstance(block["text"], str) - assert block["text"] + summary = reasoning["summary"] + assert isinstance(summary, list) + for block in summary: + assert isinstance(block, dict) + assert isinstance(block["type"], str) + assert isinstance(block["text"], str) + assert block["text"] + else: + # v1 + total_reasoning_blocks = 0 + for block in response_1.content: + if block["type"] == "reasoning": + total_reasoning_blocks += 1 + assert isinstance(block["id"], str) and block["id"].startswith("rs_") + assert isinstance(block["reasoning"], str) + assert isinstance(block["index"], int) + assert ( + total_reasoning_blocks > 1 + ) # This query typically generates multiple reasoning blocks # Check we can pass back summaries message_2 = {"role": "user", "content": "Thank you."} diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py index 4e8f57ce7e0..d1c6b254614 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py @@ -51,7 +51,11 @@ from langchain_openai import ChatOpenAI from langchain_openai.chat_models._compat import ( _FUNCTION_CALL_IDS_MAP_KEY, _convert_from_v03_ai_message, + _convert_from_v1_to_chat_completions, + _convert_from_v1_to_responses, _convert_to_v03_ai_message, + _convert_to_v1_from_chat_completions, + _convert_to_v1_from_responses, ) from langchain_openai.chat_models.base import ( _construct_lc_result_from_responses_api, @@ -2296,7 +2300,7 @@ def test_mcp_tracing() -> None: assert payload["tools"][0]["headers"]["Authorization"] == "Bearer PLACEHOLDER" -def test_compat() -> None: +def test_compat_responses_v1() -> None: # Check compatibility with v0.3 message format message_v03 = AIMessage( content=[ @@ -2357,6 +2361,421 @@ def test_compat() -> None: assert message_v03_output is not message_v03 +@pytest.mark.parametrize( + "message_v1, expected", + [ + ( + AIMessage( + [ + {"type": "reasoning", "reasoning": "Reasoning text"}, + {"type": "tool_call", "id": "call_123"}, + { + "type": "text", + "text": "Hello, world!", + "annotations": [ + {"type": "url_citation", "url": "https://example.com"} + ], + }, + ], + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + } + ], + id="chatcmpl-123", + response_metadata={"foo": "bar"}, + ), + AIMessage( + [{"type": "text", "text": "Hello, world!"}], + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + } + ], + id="chatcmpl-123", + response_metadata={"foo": "bar"}, + ), + ) + ], +) +def test_convert_from_v1_to_chat_completions( + message_v1: AIMessage, expected: AIMessage +) -> None: + result = _convert_from_v1_to_chat_completions(message_v1) + assert result == expected + + # Check no mutation + assert message_v1 != result + + +@pytest.mark.parametrize( + "message_chat_completions, expected", + [ + ( + AIMessage( + "Hello, world!", id="chatcmpl-123", response_metadata={"foo": "bar"} + ), + AIMessage( + [{"type": "text", "text": "Hello, world!"}], + id="chatcmpl-123", + response_metadata={"foo": "bar"}, + ), + ), + ( + AIMessage( + [{"type": "text", "text": "Hello, world!"}], + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + } + ], + id="chatcmpl-123", + response_metadata={"foo": "bar"}, + ), + AIMessage( + [ + {"type": "text", "text": "Hello, world!"}, + {"type": "tool_call", "id": "call_123"}, + ], + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + } + ], + id="chatcmpl-123", + response_metadata={"foo": "bar"}, + ), + ), + ( + AIMessage( + "", + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + } + ], + id="chatcmpl-123", + response_metadata={"foo": "bar"}, + additional_kwargs={"tool_calls": [{"foo": "bar"}]}, + ), + AIMessage( + [{"type": "tool_call", "id": "call_123"}], + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + } + ], + id="chatcmpl-123", + response_metadata={"foo": "bar"}, + ), + ), + ], +) +def test_convert_to_v1_from_chat_completions( + message_chat_completions: AIMessage, expected: AIMessage +) -> None: + result = _convert_to_v1_from_chat_completions(message_chat_completions) + assert result == expected + + +@pytest.mark.parametrize( + "message_v1, expected", + [ + ( + AIMessage( + [ + {"type": "reasoning", "id": "abc123"}, + {"type": "reasoning", "id": "abc234", "reasoning": "foo "}, + {"type": "reasoning", "id": "abc234", "reasoning": "bar"}, + {"type": "tool_call", "id": "call_123"}, + { + "type": "tool_call", + "id": "call_234", + "name": "get_weather_2", + "arguments": '{"location": "New York"}', + "item_id": "fc_123", + }, + {"type": "text", "text": "Hello "}, + { + "type": "text", + "text": "world", + "annotations": [ + {"type": "url_citation", "url": "https://example.com"}, + { + "type": "document_citation", + "title": "my doc", + "index": 1, + "file_id": "file_123", + }, + { + "type": "non_standard_annotation", + "value": {"bar": "baz"}, + }, + ], + }, + { + "type": "image", + "source_type": "base64", + "data": "...", + "id": "img_123", + }, + { + "type": "non_standard", + "value": {"type": "something_else", "foo": "bar"}, + }, + ], + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + }, + { + # Make values different to check we pull from content when + # available + "type": "tool_call", + "id": "call_234", + "name": "get_weather_3", + "args": {"location": "Boston"}, + }, + ], + id="resp123", + response_metadata={"foo": "bar"}, + ), + AIMessage( + [ + {"type": "reasoning", "id": "abc123"}, + { + "type": "reasoning", + "id": "abc234", + "summary": [ + {"type": "summary_text", "text": "foo "}, + {"type": "summary_text", "text": "bar"}, + ], + }, + { + "type": "function_call", + "call_id": "call_123", + "name": "get_weather", + "arguments": '{"location": "San Francisco"}', + }, + { + "type": "function_call", + "call_id": "call_234", + "name": "get_weather_2", + "arguments": '{"location": "New York"}', + "id": "fc_123", + }, + {"type": "text", "text": "Hello "}, + { + "type": "text", + "text": "world", + "annotations": [ + {"type": "url_citation", "url": "https://example.com"}, + { + "type": "file_citation", + "filename": "my doc", + "index": 1, + "file_id": "file_123", + }, + {"bar": "baz"}, + ], + }, + {"type": "image_generation_call", "id": "img_123", "result": "..."}, + {"type": "something_else", "foo": "bar"}, + ], + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + }, + { + # Make values different to check we pull from content when + # available + "type": "tool_call", + "id": "call_234", + "name": "get_weather_3", + "args": {"location": "Boston"}, + }, + ], + id="resp123", + response_metadata={"foo": "bar"}, + ), + ) + ], +) +def test_convert_from_v1_to_responses( + message_v1: AIMessage, expected: AIMessage +) -> None: + result = _convert_from_v1_to_responses(message_v1) + assert result == expected + + # Check no mutation + assert message_v1 != result + + +@pytest.mark.parametrize( + "message_responses, expected", + [ + ( + AIMessage( + [ + {"type": "reasoning", "id": "abc123"}, + { + "type": "reasoning", + "id": "abc234", + "summary": [ + {"type": "summary_text", "text": "foo "}, + {"type": "summary_text", "text": "bar"}, + ], + }, + { + "type": "function_call", + "call_id": "call_123", + "name": "get_weather", + "arguments": '{"location": "San Francisco"}', + }, + { + "type": "function_call", + "call_id": "call_234", + "name": "get_weather_2", + "arguments": '{"location": "New York"}', + "id": "fc_123", + }, + {"type": "text", "text": "Hello "}, + { + "type": "text", + "text": "world", + "annotations": [ + {"type": "url_citation", "url": "https://example.com"}, + { + "type": "file_citation", + "filename": "my doc", + "index": 1, + "file_id": "file_123", + }, + {"bar": "baz"}, + ], + }, + {"type": "image_generation_call", "id": "img_123", "result": "..."}, + {"type": "something_else", "foo": "bar"}, + ], + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + }, + { + # Make values different to check we pull from content when + # available + "type": "tool_call", + "id": "call_234", + "name": "get_weather_3", + "args": {"location": "Boston"}, + }, + ], + id="resp123", + response_metadata={"foo": "bar"}, + ), + AIMessage( + [ + {"type": "reasoning", "id": "abc123"}, + {"type": "reasoning", "id": "abc234", "reasoning": "foo "}, + {"type": "reasoning", "id": "abc234", "reasoning": "bar"}, + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "arguments": '{"location": "San Francisco"}', + }, + { + "type": "tool_call", + "id": "call_234", + "name": "get_weather_2", + "arguments": '{"location": "New York"}', + "item_id": "fc_123", + }, + {"type": "text", "text": "Hello "}, + { + "type": "text", + "text": "world", + "annotations": [ + {"type": "url_citation", "url": "https://example.com"}, + { + "type": "document_citation", + "title": "my doc", + "index": 1, + "file_id": "file_123", + }, + { + "type": "non_standard_annotation", + "value": {"bar": "baz"}, + }, + ], + }, + { + "type": "image", + "source_type": "base64", + "data": "...", + "id": "img_123", + }, + { + "type": "non_standard", + "value": {"type": "something_else", "foo": "bar"}, + }, + ], + tool_calls=[ + { + "type": "tool_call", + "id": "call_123", + "name": "get_weather", + "args": {"location": "San Francisco"}, + }, + { + # Make values different to check we pull from content when + # available + "type": "tool_call", + "id": "call_234", + "name": "get_weather_3", + "args": {"location": "Boston"}, + }, + ], + id="resp123", + response_metadata={"foo": "bar"}, + ), + ) + ], +) +def test_convert_to_v1_from_responses( + message_responses: AIMessage, expected: AIMessage +) -> None: + result = _convert_to_v1_from_responses(message_responses) + assert result == expected + + def test_get_last_messages() -> None: messages: list[BaseMessage] = [HumanMessage("Hello")] last_messages, previous_response_id = _get_last_messages(messages) diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_responses_stream.py b/libs/partners/openai/tests/unit_tests/chat_models/test_responses_stream.py index 370adcd1f1a..bee9e6fe095 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_responses_stream.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_responses_stream.py @@ -1,6 +1,7 @@ from typing import Any, Optional from unittest.mock import MagicMock, patch +import pytest from langchain_core.messages import AIMessageChunk, BaseMessageChunk from openai.types.responses import ( ResponseCompletedEvent, @@ -610,8 +611,97 @@ def _strip_none(obj: Any) -> Any: return obj -def test_responses_stream() -> None: - llm = ChatOpenAI(model="o4-mini", output_version="responses/v1") +@pytest.mark.parametrize( + "output_version, expected_content", + [ + ( + "responses/v1", + [ + { + "id": "rs_123", + "summary": [ + { + "index": 0, + "type": "summary_text", + "text": "reasoning block one", + }, + { + "index": 1, + "type": "summary_text", + "text": "another reasoning block", + }, + ], + "type": "reasoning", + "index": 0, + }, + {"type": "text", "text": "text block one", "index": 1, "id": "msg_123"}, + { + "type": "text", + "text": "another text block", + "index": 2, + "id": "msg_123", + }, + { + "id": "rs_234", + "summary": [ + {"index": 0, "type": "summary_text", "text": "more reasoning"}, + { + "index": 1, + "type": "summary_text", + "text": "still more reasoning", + }, + ], + "type": "reasoning", + "index": 3, + }, + {"type": "text", "text": "more", "index": 4, "id": "msg_234"}, + {"type": "text", "text": "text", "index": 5, "id": "msg_234"}, + ], + ), + ( + "v1", + [ + { + "type": "reasoning", + "reasoning": "reasoning block one", + "id": "rs_123", + "index": 0, + }, + { + "type": "reasoning", + "reasoning": "another reasoning block", + "id": "rs_123", + "index": 1, + }, + {"type": "text", "text": "text block one", "index": 2, "id": "msg_123"}, + { + "type": "text", + "text": "another text block", + "index": 3, + "id": "msg_123", + }, + { + "type": "reasoning", + "reasoning": "more reasoning", + "id": "rs_234", + "index": 4, + }, + { + "type": "reasoning", + "reasoning": "still more reasoning", + "id": "rs_234", + "index": 5, + }, + {"type": "text", "text": "more", "index": 6, "id": "msg_234"}, + {"type": "text", "text": "text", "index": 7, "id": "msg_234"}, + ], + ), + ], +) +def test_responses_stream(output_version: str, expected_content: list[dict]) -> None: + llm = ChatOpenAI( + model="o4-mini", use_responses_api=True, output_version=output_version + ) mock_client = MagicMock() def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager: @@ -620,36 +710,14 @@ def test_responses_stream() -> None: mock_client.responses.create = mock_create full: Optional[BaseMessageChunk] = None + chunks = [] with patch.object(llm, "root_client", mock_client): for chunk in llm.stream("test"): assert isinstance(chunk, AIMessageChunk) full = chunk if full is None else full + chunk - assert isinstance(full, AIMessageChunk) + chunks.append(chunk) - expected_content = [ - { - "id": "rs_123", - "summary": [ - {"index": 0, "type": "summary_text", "text": "reasoning block one"}, - {"index": 1, "type": "summary_text", "text": "another reasoning block"}, - ], - "type": "reasoning", - "index": 0, - }, - {"type": "text", "text": "text block one", "index": 1, "id": "msg_123"}, - {"type": "text", "text": "another text block", "index": 2, "id": "msg_123"}, - { - "id": "rs_234", - "summary": [ - {"index": 0, "type": "summary_text", "text": "more reasoning"}, - {"index": 1, "type": "summary_text", "text": "still more reasoning"}, - ], - "type": "reasoning", - "index": 3, - }, - {"type": "text", "text": "more", "index": 4, "id": "msg_234"}, - {"type": "text", "text": "text", "index": 5, "id": "msg_234"}, - ] + assert isinstance(full, AIMessageChunk) assert full.content == expected_content assert full.additional_kwargs == {} assert full.id == "resp_123"