Compare commits

...

41 Commits

Author SHA1 Message Date
Mason Daugherty
6ae6202bbe refactor: enhance OpenAI data block handling and normalize message formats 2025-08-15 17:03:03 -04:00
Mason Daugherty
2375c3a4d0 add note 2025-08-15 16:39:36 -04:00
Mason Daugherty
0199b56bda rfc test_utils to make clearer what was existing before and after, and add comments 2025-08-15 16:37:39 -04:00
Mason Daugherty
00345c4de9 tests: add more data content block tests 2025-08-15 16:28:46 -04:00
Mason Daugherty
7f9727ee08 refactor: is_data_content_block 2025-08-15 16:28:33 -04:00
Mason Daugherty
08cd5bb9b4 clarify intent of extras under data blocks 2025-08-15 16:27:47 -04:00
Mason Daugherty
987031f86c fix: _LC_ID_PREFIX back 2025-08-15 16:27:08 -04:00
Mason Daugherty
7a8c6398a4 clarify: meaning of provider 2025-08-15 16:01:29 -04:00
Mason Daugherty
f691dc348f refactor: make ensure_id public 2025-08-15 15:42:17 -04:00
Mason Daugherty
86252d2ae6 refactor: move ID prefixes 2025-08-15 15:39:36 -04:00
Mason Daugherty
8fc1973bbf test: add note about for tuple conversion in ToolMessage 2025-08-15 15:30:51 -04:00
Mason Daugherty
a3b20b0ef5 clean up id test 2025-08-15 15:28:11 -04:00
Mason Daugherty
301a425151 snapshot 2025-08-15 15:16:07 -04:00
Mason Daugherty
3db8c60112 chore: more content block formatting 2025-08-15 15:01:07 -04:00
Mason Daugherty
8d110599cb chore: more content block docstring formatting 2025-08-15 14:39:13 -04:00
Mason Daugherty
c9e847fcb8 chore: format output_version docstring 2025-08-15 14:33:59 -04:00
Mason Daugherty
601fa7d672 Merge branch 'wip-v1.0' into cc/1.0/standard_content 2025-08-15 14:31:50 -04:00
Mason Daugherty
7e39cd18c5 feat: allow kwargs on content block factories (#32568) 2025-08-15 14:30:32 -04:00
Mason Daugherty
9721684501 Merge branch 'master' into wip-v1.0 2025-08-15 14:06:34 -04:00
Mason Daugherty
a4e135b508 fix: use .get() on image URL in ImagePromptValue.to_string() 2025-08-15 13:57:50 -04:00
Mason Daugherty
d111965448 Merge branch 'wip-v1.0' into cc/1.0/standard_content 2025-08-15 13:35:57 -04:00
Chester Curme
624300cefa core: populate tool_call_chunks in content_blocks 2025-08-14 10:06:33 -04:00
Chester Curme
0aac20e655 openai: tool calls in progress 2025-08-14 09:55:20 -04:00
Chester Curme
153db48c92 openai: misc fixes for computer calls and custom tools 2025-08-13 15:32:02 -04:00
Chester Curme
803d19f31e Merge branch 'wip-v1.0' into cc/1.0/standard_content 2025-08-13 11:33:31 -04:00
Chester Curme
2f604eb9a0 openai: carry over refusals fix 2025-08-13 11:23:54 -04:00
Chester Curme
3ae37b5987 openai: integration tests pass 2025-08-13 11:12:46 -04:00
Chester Curme
0c7294f608 openai: pull in responses api integration tests from 0.4 branch 2025-08-13 10:08:37 -04:00
Chester Curme
5c961ca4f6 update test_base 2025-08-12 18:10:20 -04:00
Chester Curme
c0e4361192 core: populate tool_calls when initializing AIMessage via content_blocks 2025-08-12 18:03:19 -04:00
Chester Curme
c1d65a7d7f x 2025-08-12 18:00:14 -04:00
Chester Curme
3ae7535f42 openai: pull in _compat from 0.4 branch 2025-08-12 15:15:57 -04:00
Chester Curme
6eaa17205c implement output_version on BaseChatModel 2025-08-12 15:04:21 -04:00
Chester Curme
98d5f469e3 Revert "start on duplicate content"
This reverts commit 0ddab9ff20.
2025-08-12 11:00:02 -04:00
Chester Curme
0ddab9ff20 start on duplicate content 2025-08-12 10:59:50 -04:00
Chester Curme
91b2bb3417 Merge branch 'wip-v1.0' into cc/1.0/standard_content 2025-08-12 08:56:15 -04:00
Chester Curme
8426db47f1 update init on HumanMessage, SystemMessage, ToolMessage 2025-08-11 18:09:04 -04:00
Chester Curme
1b9ec25755 update init on aimessage 2025-08-11 16:52:08 -04:00
Chester Curme
f8244b9108 type required on tool_call_chunk; keep messages.tool.ToolCallChunk 2025-08-11 16:33:48 -04:00
Chester Curme
54a3c5f85c x 2025-08-11 14:53:12 -04:00
Chester Curme
7090060b68 select changes from wip-v0.4/core 2025-08-11 14:52:58 -04:00
30 changed files with 4595 additions and 695 deletions

View File

@@ -1,12 +1,26 @@
import re
from collections.abc import Sequence
from typing import Optional
from typing import TYPE_CHECKING, Literal, Optional, TypedDict
from langchain_core.messages import BaseMessage
if TYPE_CHECKING:
from langchain_core.messages import BaseMessage
from langchain_core.messages.content_blocks import (
KNOWN_BLOCK_TYPES,
ContentBlock,
create_audio_block,
create_file_block,
create_image_block,
create_non_standard_block,
create_plaintext_block,
)
def _is_openai_data_block(block: dict) -> bool:
"""Check if the block contains multimodal data in OpenAI Chat Completions format."""
"""Check if the block contains multimodal data in OpenAI Chat Completions format.
Supports both data and ID-style blocks (e.g. ``'file_data'`` and ``'file_id'``).
"""
if block.get("type") == "image_url":
if (
(set(block.keys()) <= {"type", "image_url", "detail"})
@@ -15,29 +29,42 @@ def _is_openai_data_block(block: dict) -> bool:
):
url = image_url.get("url")
if isinstance(url, str):
# Required per OpenAI spec
return True
# Ignore `'detail'` since it's optional and specific to OpenAI
elif block.get("type") == "file":
if (file := block.get("file")) and isinstance(file, dict):
file_data = file.get("file_data")
if isinstance(file_data, str):
file_id = file.get("file_id")
if isinstance(file_data, str) or isinstance(file_id, str):
return True
elif block.get("type") == "input_audio":
if (input_audio := block.get("input_audio")) and isinstance(input_audio, dict):
audio_data = input_audio.get("data")
audio_format = input_audio.get("format")
if (audio := block.get("audio")) and isinstance(audio, dict):
audio_data = audio.get("data")
audio_format = audio.get("format")
if isinstance(audio_data, str) and isinstance(audio_format, str):
# Both required per OpenAI spec
return True
else:
return False
# Has no `'type'` key
return False
def _parse_data_uri(uri: str) -> Optional[dict]:
"""Parse a data URI into its components. If parsing fails, return None.
class ParsedDataUri(TypedDict):
source_type: Literal["base64"]
data: str
mime_type: str
def _parse_data_uri(uri: str) -> Optional[ParsedDataUri]:
"""Parse a data URI into its components.
If parsing fails, return None. If either MIME type or data is missing, return None.
Example:
@@ -57,84 +84,350 @@ def _parse_data_uri(uri: str) -> Optional[dict]:
match = re.match(regex, uri)
if match is None:
return None
mime_type = match.group("mime_type")
data = match.group("data")
if not mime_type or not data:
return None
return {
"source_type": "base64",
"data": match.group("data"),
"mime_type": match.group("mime_type"),
"data": data,
"mime_type": mime_type,
}
def _convert_openai_format_to_data_block(block: dict) -> dict:
"""Convert OpenAI image content block to standard data content block.
def _convert_openai_format_to_data_block(block: dict) -> ContentBlock:
"""Convert OpenAI image/audio/file content block to v1 standard content block.
If parsing fails, pass-through.
Args:
block: The OpenAI image content block to convert.
Returns:
The converted standard data content block.
"""
if block["type"] == "image_url":
parsed = _parse_data_uri(block["image_url"]["url"])
if parsed is not None:
parsed["type"] = "image"
return parsed
return block
if block["type"] == "file":
parsed = _parse_data_uri(block["file"]["file_data"])
if parsed is not None:
parsed["type"] = "file"
if filename := block["file"].get("filename"):
parsed["filename"] = filename
return parsed
return block
if block.get("type") == "file" and "file_id" in block.get("file", {}):
return create_file_block(
file_id=block["file"]["file_id"],
)
if block["type"] == "input_audio":
data = block["input_audio"].get("data")
audio_format = block["input_audio"].get("format")
if data and audio_format:
return {
"type": "audio",
"source_type": "base64",
"data": data,
"mime_type": f"audio/{audio_format}",
return create_audio_block(
base64=block["audio"]["data"],
mime_type=f"audio/{block['audio']['format']}",
)
if (block["type"] == "file") and (
parsed := _parse_data_uri(block["file"]["file_data"])
):
mime_type = parsed["mime_type"]
filename = block["file"].get("filename")
return create_file_block(
base64=block["file"]["file_data"],
mime_type=mime_type,
filename=filename,
)
# base64-style image block
if (block["type"] == "image_url") and (
parsed := _parse_data_uri(block["image_url"]["url"])
):
return create_image_block(
base64=block["image_url"]["url"],
mime_type=parsed["mime_type"],
detail=block["image_url"].get("detail"), # Optional, specific to OpenAI
)
# url-style image block
if (block["type"] == "image_url") and isinstance(
block["image_url"].get("url"), str
):
return create_image_block(
url=block["image_url"]["url"],
detail=block["image_url"].get("detail"), # Optional, specific to OpenAI
)
# Escape hatch for non-standard content blocks
return create_non_standard_block(
value=block,
)
def _normalize_messages(messages: Sequence["BaseMessage"]) -> list["BaseMessage"]:
"""Normalize different message formats to LangChain v1 standard content blocks.
Chat models implement support for:
- Images in OpenAI Chat Completions format
- LangChain v1 standard content blocks
This function extends support to:
- `Audio <https://platform.openai.com/docs/api-reference/chat/create>`__ and
`file <https://platform.openai.com/docs/api-reference/files>`__ data in OpenAI
Chat Completions format
- Images are technically supported but we expect chat models to handle them
directly; this may change in the future
- LangChain v0 standard content blocks for backward compatibility
.. versionchanged:: 1.0.0
In previous versions, this function returned messages in LangChain v0 format.
Now, it returns messages in LangChain v1 format, which upgraded chat models now
expect to receive when passing back in message history. For backward
compatibility, we now allow converting v0 message content to v1 format.
.. dropdown:: v0 Content Blocks
``URLContentBlock``:
.. codeblock::
{
mime_type: NotRequired[str]
type: Literal['image', 'audio', 'file'],
source_type: Literal['url'],
url: str,
}
return block
return block
``Base64ContentBlock``:
.. codeblock::
def _normalize_messages(messages: Sequence[BaseMessage]) -> list[BaseMessage]:
"""Extend support for message formats.
{
mime_type: NotRequired[str]
type: Literal['image', 'audio', 'file'],
source_type: Literal['base64'],
data: str,
}
``IDContentBlock``:
.. codeblock::
{
type: Literal['image', 'audio', 'file'],
source_type: Literal['id'],
id: str,
}
``PlainTextContentBlock``:
.. codeblock::
{
mime_type: NotRequired[str]
type: Literal['file'],
source_type: Literal['text'],
url: str,
}
(Untested): if a v1 message is passed in, it will be returned as-is, meaning it is
safe to always pass in v1 messages to this function for assurance.
Chat models implement support for images in OpenAI Chat Completions format, as well
as other multimodal data as standard data blocks. This function extends support to
audio and file data in OpenAI Chat Completions format by converting them to standard
data blocks.
"""
# For posterity, here are the OpenAI Chat Completions schemas we expect:
#
# Chat Completions image. Can be URL-based or base64-encoded. Supports MIME types
# png, jpeg/jpg, webp, static gif:
# {
# "type": Literal['image_url'],
# "image_url": {
# "url": Union["data:$MIME_TYPE;base64,$BASE64_ENCODED_IMAGE", "$IMAGE_URL"], # noqa: E501
# "detail": Literal['low', 'high', 'auto'] = 'auto', # Only supported by OpenAI # noqa: E501
# }
# }
# Chat Completions audio:
# {
# "type": Literal['input_audio'],
# "audio": {
# "format": Literal['wav', 'mp3'],
# "data": str = "$BASE64_ENCODED_AUDIO",
# },
# }
# Chat Completions files: either base64 or pre-uploaded file ID
# {
# "type": Literal['file'],
# "file": Union[
# {
# "filename": Optional[str] = "$FILENAME",
# "file_data": str = "$BASE64_ENCODED_FILE",
# },
# {
# "file_id": str = "$FILE_ID", # For pre-uploaded files to OpenAI
# },
# ],
# }
formatted_messages = []
for message in messages:
# We preserve input messages - the caller may reuse them elsewhere and expects
# them to remain unchanged. We only create a copy if we need to translate
# (e.g. they're not already in LangChain format).
formatted_message = message
if isinstance(message.content, list):
if isinstance(message.content, str):
if formatted_message is message:
formatted_message = message.model_copy()
# Shallow-copy the content string so we can modify it
formatted_message.content = str(formatted_message.content)
formatted_message.content = [
{
"type": "text",
"text": message.content,
}
]
elif isinstance(message.content, list):
for idx, block in enumerate(message.content):
if (
isinstance(block, dict)
# Subset to (PDF) files and audio, as most relevant chat models
# support images in OAI format (and some may not yet support the
# standard data block format)
and block.get("type") in {"file", "input_audio"}
and _is_openai_data_block(block)
):
if isinstance(block, str):
if formatted_message is message:
formatted_message = message.model_copy()
# Also shallow-copy content
# Shallow-copy the content list so we can modify it
formatted_message.content = list(formatted_message.content)
formatted_message.content[idx] = {"type": "text", "text": block} # type: ignore[index] # mypy confused by .model_copy
# Handle OpenAI Chat Completions multimodal data blocks
if (
# Subset to base64 image, file, and audio
isinstance(block, dict)
and block.get("type") in {"image_url", "input_audio", "file"}
# We need to discriminate between an OpenAI formatted file and a LC
# file content block since they share the `'type'` key
and _is_openai_data_block(block)
):
# Only copy if it is an OpenAI data block that needs conversion
if formatted_message is message:
formatted_message = message.model_copy()
# Shallow-copy the content list so we can modify it
formatted_message.content = list(formatted_message.content)
formatted_message.content[idx] = ( # type: ignore[index] # mypy confused by .model_copy
# Convert OpenAI image/audio/file block to LangChain v1 standard
# content
formatted_message.content[idx] = ( # type: ignore[call-overload,index] # mypy confused by .model_copy
_convert_openai_format_to_data_block(block)
# This may return a NonStandardContentBlock if parsing fails!
)
# Handle LangChain v0 standard content blocks
# TODO: check for source_type since that disqualifies v1 blocks and
# ensures this block only checks v0
elif isinstance(block, dict) and block.get("type") in {
"image",
"audio",
"file",
}:
# Convert v0 to v1 standard content blocks
# These guard against v1 blocks as they don't have `'source_type'`
if formatted_message is message:
formatted_message = message.model_copy()
# Shallow-copy the content list so we can modify it
formatted_message.content = list(formatted_message.content)
# URL-image
if block.get("source_type") == "url" and block["type"] == "image":
formatted_message.content[idx] = create_image_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
url=block["url"],
mime_type=block.get("mime_type"),
)
# URL-audio
elif block.get("source_type") == "url" and block["type"] == "audio":
formatted_message.content[idx] = create_audio_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
url=block["url"],
mime_type=block.get("mime_type"),
)
# URL-file
elif block.get("source_type") == "url" and block["type"] == "file":
formatted_message.content[idx] = create_file_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
url=block["url"],
mime_type=block.get("mime_type"),
)
# base64-image
elif (
block.get("source_type") == "base64"
and block["type"] == "image"
):
formatted_message.content[idx] = create_image_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
base64=block["data"],
mime_type=block.get("mime_type"),
)
# base64-audio
elif (
block.get("source_type") == "base64"
and block["type"] == "audio"
):
formatted_message.content[idx] = create_audio_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
base64=block["data"],
mime_type=block.get("mime_type"),
)
# base64-file
elif (
block.get("source_type") == "base64" and block["type"] == "file"
):
formatted_message.content[idx] = create_file_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
base64=block["data"],
mime_type=block.get("mime_type"),
)
# id-image
elif block.get("source_type") == "id" and block["type"] == "image":
formatted_message.content[idx] = create_image_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
id=block["id"],
)
# id-audio
elif block.get("source_type") == "id" and block["type"] == "audio":
formatted_message.content[idx] = create_audio_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
id=block["id"],
)
# id-file
elif block.get("source_type") == "id" and block["type"] == "file":
formatted_message.content[idx] = create_file_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
id=block["id"],
)
# text-file
elif block.get("source_type") == "text" and block["type"] == "file":
formatted_message.content[idx] = create_plaintext_block( # type: ignore[call-overload,index] # mypy confused by .model_copy
text=block["url"],
# Note: `text` is the URL in this case, not the content
# This is a legacy format, so we don't expect a MIME type
# but we can still pass it if it exists
mime_type=block.get("mime_type"),
)
else: # Unsupported or malformed v0 content block
formatted_message.content[idx] = { # type: ignore[index] # mypy confused by .model_copy
"type": "non_standard",
"value": block,
}
# Validate a v1 block to pass through
elif (
isinstance(block, dict)
and "type" in block
and block["type"] in KNOWN_BLOCK_TYPES
):
# # Handle shared type keys between v1 blocks and Chat Completions
# if block["type"] == "file" and block["file"]:
# # This is a file ID block
# formatted_message.content[idx] = create_file_block( # type: ignore[call-overload,index] # mypy confused by .model_copy # noqa: E501
# id=block["file"]["file_id"],
# )
formatted_message.content[idx] = block # type: ignore[index] # mypy confused by .model_copy
# Pass through any other content block types
# If we didn't modify the message, skip creating a new instance
if formatted_message is message:
formatted_messages.append(message)
continue
# At this point, `content` will be a list of v1 standard content blocks.
formatted_messages.append(formatted_message)
return formatted_messages

View File

@@ -41,6 +41,7 @@ from langchain_core.messages import (
BaseMessageChunk,
HumanMessage,
convert_to_messages,
convert_to_openai_data_block,
convert_to_openai_image_block,
is_data_content_block,
message_chunk_to_message,
@@ -130,6 +131,19 @@ def _format_for_tracing(messages: list[BaseMessage]) -> list[BaseMessage]:
message_to_trace.content[idx] = ( # type: ignore[index] # mypy confused by .model_copy
convert_to_openai_image_block(block)
)
elif (
block.get("type") == "file"
and is_data_content_block(block)
and "base64" in block
):
if message_to_trace is message:
# Shallow copy
message_to_trace = message.model_copy()
message_to_trace.content = list(message_to_trace.content)
message_to_trace.content[idx] = convert_to_openai_data_block( # type: ignore[index]
block
)
elif len(block) == 1 and "type" not in block:
# Tracing assumes all content blocks have a "type" key. Here
# we add this key if it is missing, and there's an obvious
@@ -320,6 +334,21 @@ class BaseChatModel(BaseLanguageModel[BaseMessage], ABC):
"""
output_version: str = "v0"
"""Version of ``AIMessage`` output format to use.
This field is used to roll-out new output formats for chat model ``AIMessage``s
in a backwards-compatible way.
``'v1'`` standardizes output format using a list of typed ContentBlock dicts. We
recommend this for new applications.
All chat models currently support the default of ``'v0'``.
.. versionadded:: 1.0
"""
@model_validator(mode="before")
@classmethod
def raise_deprecation(cls, values: dict) -> Any:

View File

@@ -21,6 +21,7 @@ from langchain_core._import_utils import import_attr
if TYPE_CHECKING:
from langchain_core.messages.ai import (
_LC_ID_PREFIX,
AIMessage,
AIMessageChunk,
)
@@ -33,9 +34,34 @@ if TYPE_CHECKING:
)
from langchain_core.messages.chat import ChatMessage, ChatMessageChunk
from langchain_core.messages.content_blocks import (
LC_AUTO_PREFIX,
LC_ID_PREFIX,
Annotation,
AudioContentBlock,
Citation,
CodeInterpreterCall,
CodeInterpreterOutput,
CodeInterpreterResult,
ContentBlock,
DataContentBlock,
FileContentBlock,
ImageContentBlock,
NonStandardAnnotation,
NonStandardContentBlock,
PlainTextContentBlock,
ReasoningContentBlock,
TextContentBlock,
VideoContentBlock,
WebSearchCall,
WebSearchResult,
convert_to_openai_data_block,
convert_to_openai_image_block,
ensure_id,
is_data_content_block,
is_reasoning_block,
is_text_block,
is_tool_call_block,
is_tool_call_chunk,
)
from langchain_core.messages.function import FunctionMessage, FunctionMessageChunk
from langchain_core.messages.human import HumanMessage, HumanMessageChunk
@@ -63,34 +89,60 @@ if TYPE_CHECKING:
)
__all__ = (
"LC_AUTO_PREFIX",
"LC_ID_PREFIX",
"_LC_ID_PREFIX",
"AIMessage",
"AIMessageChunk",
"Annotation",
"AnyMessage",
"AudioContentBlock",
"BaseMessage",
"BaseMessageChunk",
"ChatMessage",
"ChatMessageChunk",
"Citation",
"CodeInterpreterCall",
"CodeInterpreterOutput",
"CodeInterpreterResult",
"ContentBlock",
"DataContentBlock",
"FileContentBlock",
"FunctionMessage",
"FunctionMessageChunk",
"HumanMessage",
"HumanMessageChunk",
"ImageContentBlock",
"InvalidToolCall",
"MessageLikeRepresentation",
"NonStandardAnnotation",
"NonStandardContentBlock",
"PlainTextContentBlock",
"ReasoningContentBlock",
"RemoveMessage",
"SystemMessage",
"SystemMessageChunk",
"TextContentBlock",
"ToolCall",
"ToolCallChunk",
"ToolMessage",
"ToolMessageChunk",
"VideoContentBlock",
"WebSearchCall",
"WebSearchResult",
"_message_from_dict",
"convert_to_messages",
"convert_to_openai_data_block",
"convert_to_openai_image_block",
"convert_to_openai_messages",
"ensure_id",
"filter_messages",
"get_buffer_string",
"is_data_content_block",
"is_reasoning_block",
"is_text_block",
"is_tool_call_block",
"is_tool_call_chunk",
"merge_content",
"merge_message_runs",
"message_chunk_to_message",
@@ -101,27 +153,46 @@ __all__ = (
)
_dynamic_imports = {
"ensure_id": "content_blocks",
"AIMessage": "ai",
"AIMessageChunk": "ai",
"Annotation": "content_blocks",
"AudioContentBlock": "content_blocks",
"BaseMessage": "base",
"BaseMessageChunk": "base",
"merge_content": "base",
"message_to_dict": "base",
"messages_to_dict": "base",
"Citation": "content_blocks",
"ContentBlock": "content_blocks",
"ChatMessage": "chat",
"ChatMessageChunk": "chat",
"CodeInterpreterCall": "content_blocks",
"CodeInterpreterOutput": "content_blocks",
"CodeInterpreterResult": "content_blocks",
"DataContentBlock": "content_blocks",
"FileContentBlock": "content_blocks",
"FunctionMessage": "function",
"FunctionMessageChunk": "function",
"HumanMessage": "human",
"HumanMessageChunk": "human",
"NonStandardAnnotation": "content_blocks",
"NonStandardContentBlock": "content_blocks",
"PlainTextContentBlock": "content_blocks",
"ReasoningContentBlock": "content_blocks",
"RemoveMessage": "modifier",
"SystemMessage": "system",
"SystemMessageChunk": "system",
"WebSearchCall": "content_blocks",
"WebSearchResult": "content_blocks",
"ImageContentBlock": "content_blocks",
"InvalidToolCall": "tool",
"TextContentBlock": "content_blocks",
"ToolCall": "tool",
"ToolCallChunk": "tool",
"ToolMessage": "tool",
"ToolMessageChunk": "tool",
"VideoContentBlock": "content_blocks",
"AnyMessage": "utils",
"MessageLikeRepresentation": "utils",
"_message_from_dict": "utils",
@@ -132,6 +203,10 @@ _dynamic_imports = {
"filter_messages": "utils",
"get_buffer_string": "utils",
"is_data_content_block": "content_blocks",
"is_reasoning_block": "content_blocks",
"is_text_block": "content_blocks",
"is_tool_call_block": "content_blocks",
"is_tool_call_chunk": "content_blocks",
"merge_message_runs": "utils",
"message_chunk_to_message": "utils",
"messages_from_dict": "utils",

View File

@@ -3,16 +3,13 @@
import json
import logging
import operator
from typing import Any, Literal, Optional, Union, cast
from typing import Any, Literal, Optional, Union, cast, overload
from pydantic import model_validator
from typing_extensions import NotRequired, Self, TypedDict, override
from langchain_core.messages.base import (
BaseMessage,
BaseMessageChunk,
merge_content,
)
from langchain_core.messages import content_blocks as types
from langchain_core.messages.base import BaseMessage, BaseMessageChunk, merge_content
from langchain_core.messages.tool import (
InvalidToolCall,
ToolCall,
@@ -20,23 +17,16 @@ from langchain_core.messages.tool import (
default_tool_chunk_parser,
default_tool_parser,
)
from langchain_core.messages.tool import (
invalid_tool_call as create_invalid_tool_call,
)
from langchain_core.messages.tool import (
tool_call as create_tool_call,
)
from langchain_core.messages.tool import (
tool_call_chunk as create_tool_call_chunk,
)
from langchain_core.messages.tool import invalid_tool_call as create_invalid_tool_call
from langchain_core.messages.tool import tool_call as create_tool_call
from langchain_core.messages.tool import tool_call_chunk as create_tool_call_chunk
from langchain_core.utils._merge import merge_dicts, merge_lists
from langchain_core.utils.json import parse_partial_json
from langchain_core.utils.usage import _dict_int_op
logger = logging.getLogger(__name__)
_LC_ID_PREFIX = "run-"
_LC_ID_PREFIX = types.LC_ID_PREFIX
class InputTokenDetails(TypedDict, total=False):
@@ -180,16 +170,42 @@ class AIMessage(BaseMessage):
type: Literal["ai"] = "ai"
"""The type of the message (used for deserialization). Defaults to "ai"."""
@overload
def __init__(
self, content: Union[str, list[Union[str, dict]]], **kwargs: Any
) -> None:
"""Pass in content as positional arg.
self,
content: Union[str, list[Union[str, dict]]],
**kwargs: Any,
) -> None: ...
Args:
content: The content of the message.
kwargs: Additional arguments to pass to the parent class.
"""
super().__init__(content=content, **kwargs)
@overload
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None: ...
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None:
"""Specify ``content`` as positional arg or ``content_blocks`` for typing."""
if content_blocks is not None:
# If there are tool calls in content_blocks, but not in tool_calls, add them
content_tool_calls = [
block for block in content_blocks if block.get("type") == "tool_call"
]
if content_tool_calls and "tool_calls" not in kwargs:
kwargs["tool_calls"] = content_tool_calls
super().__init__(
content=cast("Union[str, list[Union[str, dict]]]", content_blocks),
**kwargs,
)
else:
super().__init__(content=content, **kwargs)
@property
def lc_attributes(self) -> dict:
@@ -199,6 +215,34 @@ class AIMessage(BaseMessage):
"invalid_tool_calls": self.invalid_tool_calls,
}
@property
def content_blocks(self) -> list[types.ContentBlock]:
"""Return content blocks of the message."""
blocks = super().content_blocks
if self.tool_calls:
# Add from tool_calls if missing from content
content_tool_call_ids = {
block.get("id")
for block in self.content
if isinstance(block, dict) and block.get("type") == "tool_call"
}
for tool_call in self.tool_calls:
if (id_ := tool_call.get("id")) and id_ not in content_tool_call_ids:
tool_call_block: types.ToolCall = {
"type": "tool_call",
"id": id_,
"name": tool_call["name"],
"args": tool_call["args"],
}
if "index" in tool_call:
tool_call_block["index"] = tool_call["index"]
if "extras" in tool_call:
tool_call_block["extras"] = tool_call["extras"]
blocks.append(tool_call_block)
return blocks
# TODO: remove this logic if possible, reducing breaking nature of changes
@model_validator(mode="before")
@classmethod
@@ -227,7 +271,9 @@ class AIMessage(BaseMessage):
# Ensure "type" is properly set on all tool call-like dicts.
if tool_calls := values.get("tool_calls"):
values["tool_calls"] = [
create_tool_call(**{k: v for k, v in tc.items() if k != "type"})
create_tool_call(
**{k: v for k, v in tc.items() if k not in ("type", "extras")}
)
for tc in tool_calls
]
if invalid_tool_calls := values.get("invalid_tool_calls"):
@@ -306,6 +352,38 @@ class AIMessageChunk(AIMessage, BaseMessageChunk):
"invalid_tool_calls": self.invalid_tool_calls,
}
@property
def content_blocks(self) -> list[types.ContentBlock]:
"""Return content blocks of the message."""
blocks = super().content_blocks
if self.tool_call_chunks:
blocks = [
block
for block in blocks
if block["type"] not in ("tool_call", "invalid_tool_call")
]
# Add from tool_call_chunks if missing from content
content_tool_call_ids = {
block.get("id")
for block in self.content
if isinstance(block, dict) and block.get("type") == "tool_call_chunk"
}
for chunk in self.tool_call_chunks:
if (id_ := chunk.get("id")) and id_ not in content_tool_call_ids:
tool_call_chunk_block: types.ToolCallChunk = {
"type": "tool_call_chunk",
"id": id_,
"name": chunk["name"],
"args": chunk["args"],
"index": chunk.get("index"),
}
if "extras" in chunk:
tool_call_chunk_block["extras"] = chunk["extras"] # type: ignore[typeddict-item]
blocks.append(tool_call_chunk_block)
return blocks
@model_validator(mode="after")
def init_tool_calls(self) -> Self:
"""Initialize tool calls from tool call chunks.
@@ -431,17 +509,27 @@ def add_ai_message_chunks(
chunk_id = None
candidates = [left.id] + [o.id for o in others]
# first pass: pick the first non-run-* id
# first pass: pick the first provider-assigned id (non-run-* and non-lc_*)
for id_ in candidates:
if id_ and not id_.startswith(_LC_ID_PREFIX):
if (
id_
and not id_.startswith(types.LC_ID_PREFIX)
and not id_.startswith(types.LC_AUTO_PREFIX)
):
chunk_id = id_
break
else:
# second pass: no provider-assigned id found, just take the first non-null
# second pass: prefer lc_run-* ids over lc_* ids
for id_ in candidates:
if id_:
if id_ and id_.startswith(types.LC_ID_PREFIX):
chunk_id = id_
break
else:
# third pass: take any remaining id (auto-generated lc_* ids)
for id_ in candidates:
if id_:
chunk_id = id_
break
return left.__class__(
example=left.example,

View File

@@ -2,11 +2,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional, Union, cast
from typing import TYPE_CHECKING, Any, Optional, Union, cast, overload
from pydantic import ConfigDict, Field
from langchain_core.language_models._utils import _convert_openai_format_to_data_block
from langchain_core.load.serializable import Serializable
from langchain_core.messages import content_blocks as types
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
@@ -61,15 +63,32 @@ class BaseMessage(Serializable):
extra="allow",
)
@overload
def __init__(
self, content: Union[str, list[Union[str, dict]]], **kwargs: Any
) -> None:
"""Pass in content as positional arg.
self,
content: Union[str, list[Union[str, dict]]],
**kwargs: Any,
) -> None: ...
Args:
content: The string contents of the message.
"""
super().__init__(content=content, **kwargs)
@overload
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None: ...
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None:
"""Specify ``content`` as positional arg or ``content_blocks`` for typing."""
if content_blocks is not None:
super().__init__(content=content_blocks, **kwargs)
else:
super().__init__(content=content, **kwargs)
@classmethod
def is_lc_serializable(cls) -> bool:
@@ -88,6 +107,51 @@ class BaseMessage(Serializable):
"""
return ["langchain", "schema", "messages"]
@property
def content_blocks(self) -> list[types.ContentBlock]:
"""Return the content as a list of standard ``ContentBlock``s.
To use this property, the corresponding chat model must support
``message_version='v1'`` or higher:
.. code-block:: python
from langchain.chat_models import init_chat_model
llm = init_chat_model("...", message_version="v1")
Otherwise, does best-effort parsing to standard types.
"""
blocks: list[types.ContentBlock] = []
content = (
[self.content]
if isinstance(self.content, str) and self.content
else self.content
)
for item in content:
if isinstance(item, str):
blocks.append({"type": "text", "text": item})
elif isinstance(item, dict):
item_type = item.get("type")
if item_type in types.KNOWN_OPENAI_BLOCK_TYPES:
# OpenAI-specific content blocks
if item_type in {"image_url", "input_audio"}:
blocks.append(_convert_openai_format_to_data_block(item))
else:
blocks.append(cast("types.ContentBlock", item))
if item_type not in types.KNOWN_BLOCK_TYPES:
msg = (
f"Non-standard content block type '{item_type}'. Ensure "
"the model supports `output_version='v1'` or higher and "
"that this attribute is set on initialization."
)
raise ValueError(msg)
blocks.append(cast("types.ContentBlock", item))
else:
pass
return blocks
def text(self) -> str:
"""Get the text content of the message.

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
"""Human message."""
from typing import Any, Literal, Union
from typing import Any, Literal, Optional, Union, cast, overload
from langchain_core.messages import content_blocks as types
from langchain_core.messages.base import BaseMessage, BaseMessageChunk
@@ -41,16 +42,35 @@ class HumanMessage(BaseMessage):
type: Literal["human"] = "human"
"""The type of the message (used for serialization). Defaults to "human"."""
@overload
def __init__(
self, content: Union[str, list[Union[str, dict]]], **kwargs: Any
) -> None:
"""Pass in content as positional arg.
self,
content: Union[str, list[Union[str, dict]]],
**kwargs: Any,
) -> None: ...
Args:
content: The string contents of the message.
kwargs: Additional fields to pass to the message.
"""
super().__init__(content=content, **kwargs)
@overload
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None: ...
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None:
"""Specify ``content`` as positional arg or ``content_blocks`` for typing."""
if content_blocks is not None:
super().__init__(
content=cast("Union[str, list[Union[str, dict]]]", content_blocks),
**kwargs,
)
else:
super().__init__(content=content, **kwargs)
class HumanMessageChunk(HumanMessage, BaseMessageChunk):

View File

@@ -1,7 +1,8 @@
"""System message."""
from typing import Any, Literal, Union
from typing import Any, Literal, Optional, Union, cast, overload
from langchain_core.messages import content_blocks as types
from langchain_core.messages.base import BaseMessage, BaseMessageChunk
@@ -34,16 +35,35 @@ class SystemMessage(BaseMessage):
type: Literal["system"] = "system"
"""The type of the message (used for serialization). Defaults to "system"."""
@overload
def __init__(
self, content: Union[str, list[Union[str, dict]]], **kwargs: Any
) -> None:
"""Pass in content as positional arg.
self,
content: Union[str, list[Union[str, dict]]],
**kwargs: Any,
) -> None: ...
Args:
content: The string contents of the message.
kwargs: Additional fields to pass to the message.
"""
super().__init__(content=content, **kwargs)
@overload
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None: ...
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None:
"""Specify ``content`` as positional arg or ``content_blocks`` for typing."""
if content_blocks is not None:
super().__init__(
content=cast("Union[str, list[Union[str, dict]]]", content_blocks),
**kwargs,
)
else:
super().__init__(content=content, **kwargs)
class SystemMessageChunk(SystemMessage, BaseMessageChunk):

View File

@@ -1,13 +1,16 @@
"""Messages for tools."""
import json
from typing import Any, Literal, Optional, Union
from typing import Any, Literal, Optional, Union, cast, overload
from uuid import UUID
from pydantic import Field, model_validator
from typing_extensions import NotRequired, TypedDict, override
from langchain_core.messages import content_blocks as types
from langchain_core.messages.base import BaseMessage, BaseMessageChunk, merge_content
from langchain_core.messages.content_blocks import InvalidToolCall as InvalidToolCall
from langchain_core.messages.content_blocks import ToolCall as ToolCall
from langchain_core.utils._merge import merge_dicts, merge_obj
@@ -133,16 +136,35 @@ class ToolMessage(BaseMessage, ToolOutputMixin):
values["tool_call_id"] = str(tool_call_id)
return values
@overload
def __init__(
self, content: Union[str, list[Union[str, dict]]], **kwargs: Any
) -> None:
"""Create a ToolMessage.
self,
content: Union[str, list[Union[str, dict]]],
**kwargs: Any,
) -> None: ...
Args:
content: The string contents of the message.
**kwargs: Additional fields.
"""
super().__init__(content=content, **kwargs)
@overload
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None: ...
def __init__(
self,
content: Optional[Union[str, list[Union[str, dict]]]] = None,
content_blocks: Optional[list[types.ContentBlock]] = None,
**kwargs: Any,
) -> None:
"""Specify ``content`` as positional arg or ``content_blocks`` for typing."""
if content_blocks is not None:
super().__init__(
content=cast("Union[str, list[Union[str, dict]]]", content_blocks),
**kwargs,
)
else:
super().__init__(content=content, **kwargs)
class ToolMessageChunk(ToolMessage, BaseMessageChunk):
@@ -177,37 +199,6 @@ class ToolMessageChunk(ToolMessage, BaseMessageChunk):
return super().__add__(other)
class ToolCall(TypedDict):
"""Represents a request to call a tool.
Example:
.. code-block:: python
{
"name": "foo",
"args": {"a": 1},
"id": "123"
}
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
"""
name: str
"""The name of the tool to be called."""
args: dict[str, Any]
"""The arguments to the tool call."""
id: Optional[str]
"""An identifier associated with the tool call.
An identifier is needed to associate a tool call request with a tool
call result in events when multiple concurrent tool calls are made.
"""
type: NotRequired[Literal["tool_call"]]
def tool_call(
*,
name: str,
@@ -276,24 +267,6 @@ def tool_call_chunk(
)
class InvalidToolCall(TypedDict):
"""Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
(e.g., invalid JSON arguments.)
"""
name: Optional[str]
"""The name of the tool to be called."""
args: Optional[str]
"""The arguments to the tool call."""
id: Optional[str]
"""An identifier associated with the tool call."""
error: Optional[str]
"""An error message associated with the tool call."""
type: NotRequired[Literal["invalid_tool_call"]]
def invalid_tool_call(
*,
name: Optional[str] = None,

View File

@@ -123,7 +123,7 @@ class ImagePromptValue(PromptValue):
def to_string(self) -> str:
"""Return prompt (image URL) as string."""
return self.image_url["url"]
return self.image_url.get("url", "")
def to_messages(self) -> list[BaseMessage]:
"""Return prompt (image URL) as messages."""

View File

@@ -212,12 +212,29 @@ async def test_callback_handlers() -> None:
def test_chat_model_inputs() -> None:
fake = ParrotFakeChatModel()
# Do we need to parameterize over both versions?
# fake = ParrotFakeChatModel()
assert fake.invoke("hello") == _any_id_human_message(content="hello")
assert fake.invoke([("ai", "blah")]) == _any_id_ai_message(content="blah")
# assert fake.invoke("hello") == _any_id_human_message(
# content=[{"type": "text", "text": "hello"}]
# )
# assert fake.invoke([("ai", "blah")]) == _any_id_ai_message(
# content=[{"type": "text", "text": "blah"}]
# )
# assert fake.invoke([AIMessage(content="blah")]) == _any_id_ai_message(
# content=[{"type": "text", "text": "blah"}]
# )
fake = ParrotFakeChatModel(output_version="v1")
assert fake.invoke("hello") == _any_id_human_message(
content=[{"type": "text", "text": "hello"}]
)
assert fake.invoke([("ai", "blah")]) == _any_id_ai_message(
content=[{"type": "text", "text": "blah"}]
)
assert fake.invoke([AIMessage(content="blah")]) == _any_id_ai_message(
content="blah"
content=[{"type": "text", "text": "blah"}]
)

View File

@@ -428,43 +428,44 @@ class FakeChatModelStartTracer(FakeTracer):
def test_trace_images_in_openai_format() -> None:
"""Test that images are traced in OpenAI format."""
llm = ParrotFakeChatModel()
messages = [
{
"role": "user",
"content": [
{
"type": "image",
"source_type": "url",
"url": "https://example.com/image.png",
}
],
}
]
tracer = FakeChatModelStartTracer()
response = llm.invoke(messages, config={"callbacks": [tracer]})
assert tracer.messages == [
[
[
HumanMessage(
content=[
{
"type": "image_url",
"image_url": {"url": "https://example.com/image.png"},
}
]
)
]
]
]
# Test no mutation
assert response.content == [
{
"type": "image",
"source_type": "url",
"url": "https://example.com/image.png",
}
]
# TODO: trace in new format, or add way to trace in both formats?
# llm = ParrotFakeChatModel()
# messages = [
# {
# "role": "user",
# # v0 format
# "content": [
# {
# "type": "image",
# "source_type": "url",
# "url": "https://example.com/image.png",
# }
# ],
# }
# ]
# tracer = FakeChatModelStartTracer()
# response = llm.invoke(messages, config={"callbacks": [tracer]})
# assert tracer.messages == [
# [
# [
# HumanMessage(
# content=[
# {
# "type": "image_url",
# "image_url": {"url": "https://example.com/image.png"},
# }
# ]
# )
# ]
# ]
# ]
# # Passing in a v0 should return a v1
# assert response.content == [
# {
# "type": "image",
# "url": "https://example.com/image.png",
# }
# ]
def test_trace_content_blocks_with_no_type_key() -> None:
@@ -478,7 +479,7 @@ def test_trace_content_blocks_with_no_type_key() -> None:
"type": "text",
"text": "Hello",
},
{
{ # Will be converted to NonStandardContentBlock
"cachePoint": {"type": "default"},
},
],
@@ -495,8 +496,8 @@ def test_trace_content_blocks_with_no_type_key() -> None:
"type": "text",
"text": "Hello",
},
{
"type": "cachePoint",
{ # For tracing, we are concerned with how messages are _sent_
"type": "cachePoint", # TODO: how is this decided?
"cachePoint": {"type": "default"},
},
]
@@ -504,20 +505,20 @@ def test_trace_content_blocks_with_no_type_key() -> None:
]
]
]
# Test no mutation
assert response.content == [
{
"type": "text",
"text": "Hello",
},
{
"cachePoint": {"type": "default"},
"type": "non_standard",
"value": {"cachePoint": {"type": "default"}},
},
]
def test_extend_support_to_openai_multimodal_formats() -> None:
"""Test that chat models normalize OpenAI file and audio inputs."""
"""Test that chat models normalize OpenAI file and audio inputs to v1."""
llm = ParrotFakeChatModel()
messages = [
{
@@ -539,98 +540,65 @@ def test_extend_support_to_openai_multimodal_formats() -> None:
"file_data": "data:application/pdf;base64,<base64 string>",
},
},
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,<base64 string>",
},
},
{
"type": "file",
"file": {"file_id": "<file id>"},
},
{
"type": "input_audio",
"input_audio": {"data": "<base64 data>", "format": "wav"},
"audio": {
"format": "wav",
"data": "data:audio/wav;base64,<base64 string>",
},
},
],
},
]
expected_content = [
{"type": "text", "text": "Hello"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/image.png"},
{"type": "text", "text": "Hello"}, # TextContentBlock
{ # Chat Completions Image becomes ImageContentBlock after invoke
"type": "image",
"url": "https://example.com/image.png",
},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."},
{ # ...
"type": "image",
"base64": "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
"mime_type": "image/jpeg",
},
{
{ # FileContentBlock
"type": "file",
"source_type": "base64",
"data": "<base64 string>",
"base64": "data:application/pdf;base64,<base64 string>",
"mime_type": "application/pdf",
"filename": "draconomicon.pdf",
"extras": {"filename": "draconomicon.pdf"},
},
{
{ # ...
"type": "file",
"source_type": "base64",
"data": "<base64 string>",
"mime_type": "application/pdf",
"file_id": "<file id>",
},
{
"type": "file",
"file": {"file_id": "<file id>"},
},
{
{ # AudioContentBlock
"type": "audio",
"source_type": "base64",
"data": "<base64 data>",
"base64": "data:audio/wav;base64,<base64 string>",
"mime_type": "audio/wav",
},
]
response = llm.invoke(messages)
assert response.content == expected_content
# Test no mutation
assert messages[0]["content"] == [
{"type": "text", "text": "Hello"},
{
"type": "image_url",
"image_url": {"url": "https://example.com/image.png"},
},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."},
},
{
"type": "file",
"file": {
"filename": "draconomicon.pdf",
"file_data": "data:application/pdf;base64,<base64 string>",
},
},
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,<base64 string>",
},
},
{
"type": "file",
"file": {"file_id": "<file id>"},
},
{
"type": "input_audio",
"input_audio": {"data": "<base64 data>", "format": "wav"},
},
]
# Check structure, ignoring auto-generated IDs
actual_content = response.content
assert len(actual_content) == len(expected_content)
for i, (actual, expected) in enumerate(zip(actual_content, expected_content)):
if isinstance(actual, dict) and "id" in actual:
# Remove auto-generated id for comparison
actual_without_id = {k: v for k, v in actual.items() if k != "id"}
assert actual_without_id == expected, f"Mismatch at index {i}"
else:
assert actual == expected, f"Mismatch at index {i}"
def test_normalize_messages_edge_cases() -> None:
# Test some blocks that should pass through
messages = [
# Test unrecognized blocks come back as NonStandardContentBlock
input_messages = [
HumanMessage(
content=[
{
@@ -639,18 +607,55 @@ def test_normalize_messages_edge_cases() -> None:
},
{
"type": "input_file",
"file_data": "uri",
"file_data": "uri", # Malformed base64
"filename": "file-name",
},
{
"type": "input_audio",
"input_audio": "uri",
"input_audio": "uri", # Not nested in `audio`
},
{
"type": "input_image",
"image_url": "uri",
"image_url": "uri", # Not nested in `image_url`
},
]
)
]
assert messages == _normalize_messages(messages)
expected_messages = [
HumanMessage(
content=[
{
"type": "non_standard",
"value": {
"type": "file",
"file": "uri",
},
},
{
"type": "non_standard",
"value": {
"type": "input_file",
"file_data": "uri",
"filename": "file-name",
},
},
{
"type": "non_standard",
"value": {
"type": "input_audio",
"input_audio": "uri",
},
},
{
"type": "non_standard",
"value": {
"type": "input_image",
"image_url": "uri",
},
},
]
)
]
assert _normalize_messages(input_messages) == expected_messages

View File

@@ -301,8 +301,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)]"
)

View File

@@ -215,7 +215,8 @@ def test_rate_limit_skips_cache() -> None:
(
'[{"lc": 1, "type": "constructor", "id": ["langchain", "schema", '
'"messages", '
'"HumanMessage"], "kwargs": {"content": "foo", "type": "human"}}]',
'"HumanMessage"], "kwargs": {"content": [{"type": "text", "text": "foo"}], '
'"type": "human"}}]',
"[('_type', 'generic-fake-chat-model'), ('stop', None)]",
)
]

View File

@@ -1,5 +1,6 @@
from langchain_core.load import dumpd, load
from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.messages import content_blocks as types
from langchain_core.messages.ai import (
InputTokenDetails,
OutputTokenDetails,
@@ -196,3 +197,116 @@ def test_add_ai_message_chunks_usage() -> None:
output_token_details=OutputTokenDetails(audio=1, reasoning=2),
),
)
def test_content_blocks() -> None:
message = AIMessage(
"",
tool_calls=[
{"type": "tool_call", "name": "foo", "args": {"a": "b"}, "id": "abc_123"}
],
)
assert len(message.content_blocks) == 1
assert message.content_blocks[0]["type"] == "tool_call"
assert message.content_blocks == [
{"type": "tool_call", "id": "abc_123", "name": "foo", "args": {"a": "b"}}
]
assert message.content == ""
message = AIMessage(
"foo",
tool_calls=[
{"type": "tool_call", "name": "foo", "args": {"a": "b"}, "id": "abc_123"}
],
)
assert len(message.content_blocks) == 2
assert message.content_blocks[0]["type"] == "text"
assert message.content_blocks[1]["type"] == "tool_call"
assert message.content_blocks == [
{"type": "text", "text": "foo"},
{"type": "tool_call", "id": "abc_123", "name": "foo", "args": {"a": "b"}},
]
assert message.content == "foo"
# With standard blocks
standard_content: list[types.ContentBlock] = [
{"type": "reasoning", "reasoning": "foo"},
{"type": "text", "text": "bar"},
{
"type": "text",
"text": "baz",
"annotations": [{"type": "citation", "url": "http://example.com"}],
},
{
"type": "image",
"url": "http://example.com/image.png",
"extras": {"foo": "bar"},
},
{
"type": "non_standard",
"value": {"custom_key": "custom_value", "another_key": 123},
},
{
"type": "tool_call",
"name": "foo",
"args": {"a": "b"},
"id": "abc_123",
},
]
missing_tool_call: types.ToolCall = {
"type": "tool_call",
"name": "bar",
"args": {"c": "d"},
"id": "abc_234",
}
message = AIMessage(
content_blocks=standard_content,
tool_calls=[
{"type": "tool_call", "name": "foo", "args": {"a": "b"}, "id": "abc_123"},
missing_tool_call,
],
)
assert message.content_blocks == [*standard_content, missing_tool_call]
# Check we auto-populate tool_calls
standard_content = [
{"type": "text", "text": "foo"},
{
"type": "tool_call",
"name": "foo",
"args": {"a": "b"},
"id": "abc_123",
},
missing_tool_call,
]
message = AIMessage(content_blocks=standard_content)
assert message.tool_calls == [
{"type": "tool_call", "name": "foo", "args": {"a": "b"}, "id": "abc_123"},
missing_tool_call,
]
# Chunks
message = AIMessageChunk(
content="",
tool_call_chunks=[
{
"type": "tool_call_chunk",
"name": "foo",
"args": "",
"id": "abc_123",
"index": 0,
}
],
)
assert len(message.content_blocks) == 1
assert message.content_blocks[0]["type"] == "tool_call_chunk"
assert message.content_blocks == [
{
"type": "tool_call_chunk",
"name": "foo",
"args": "",
"id": "abc_123",
"index": 0,
}
]
assert message.content == ""

View File

@@ -5,26 +5,48 @@ EXPECTED_ALL = [
"_message_from_dict",
"AIMessage",
"AIMessageChunk",
"Annotation",
"AnyMessage",
"AudioContentBlock",
"BaseMessage",
"BaseMessageChunk",
"ContentBlock",
"ChatMessage",
"ChatMessageChunk",
"Citation",
"CodeInterpreterCall",
"CodeInterpreterOutput",
"CodeInterpreterResult",
"DataContentBlock",
"FileContentBlock",
"FunctionMessage",
"FunctionMessageChunk",
"HumanMessage",
"HumanMessageChunk",
"ImageContentBlock",
"InvalidToolCall",
"NonStandardAnnotation",
"NonStandardContentBlock",
"PlainTextContentBlock",
"SystemMessage",
"SystemMessageChunk",
"TextContentBlock",
"ToolCall",
"ToolCallChunk",
"ToolMessage",
"ToolMessageChunk",
"VideoContentBlock",
"WebSearchCall",
"WebSearchResult",
"ReasoningContentBlock",
"RemoveMessage",
"convert_to_messages",
"get_buffer_string",
"is_data_content_block",
"is_reasoning_block",
"is_text_block",
"is_tool_call_block",
"is_tool_call_chunk",
"merge_content",
"message_chunk_to_message",
"message_to_dict",

View File

@@ -1215,13 +1215,14 @@ def test_convert_to_openai_messages_developer() -> None:
def test_convert_to_openai_messages_multimodal() -> None:
"""v0 and v1 content to OpenAI messages conversion."""
messages = [
HumanMessage(
content=[
# Prior v0 blocks
{"type": "text", "text": "Text message"},
{
"type": "image",
"source_type": "url",
"url": "https://example.com/test.png",
},
{
@@ -1238,6 +1239,7 @@ def test_convert_to_openai_messages_multimodal() -> None:
"filename": "test.pdf",
},
{
# OpenAI Chat Completions file format
"type": "file",
"file": {
"filename": "draconomicon.pdf",
@@ -1262,22 +1264,47 @@ def test_convert_to_openai_messages_multimodal() -> None:
"format": "wav",
},
},
# v1 Additions
{
"type": "image",
"source_type": "url", # backward compatibility v0 block field
"url": "https://example.com/test.png",
},
{
"type": "image",
"base64": "<base64 string>",
"mime_type": "image/png",
},
{
"type": "file",
"base64": "<base64 string>",
"mime_type": "application/pdf",
"filename": "test.pdf", # backward compatibility v0 block field
},
{
"type": "file",
"file_id": "file-abc123",
},
{
"type": "audio",
"base64": "<base64 string>",
"mime_type": "audio/wav",
},
]
)
]
result = convert_to_openai_messages(messages, text_format="block")
assert len(result) == 1
message = result[0]
assert len(message["content"]) == 8
assert len(message["content"]) == 13
# Test adding filename
# Test auto-adding filename
messages = [
HumanMessage(
content=[
{
"type": "file",
"source_type": "base64",
"data": "<base64 string>",
"base64": "<base64 string>",
"mime_type": "application/pdf",
},
]
@@ -1290,6 +1317,7 @@ def test_convert_to_openai_messages_multimodal() -> None:
assert len(message["content"]) == 1
block = message["content"][0]
assert block == {
# OpenAI Chat Completions file format
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,<base64 string>",

View File

@@ -726,7 +726,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -752,6 +752,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -763,6 +767,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -781,9 +789,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -998,12 +1007,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -1015,6 +1035,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -1026,9 +1050,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',
@@ -2158,7 +2183,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -2184,6 +2209,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -2195,6 +2224,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -2213,9 +2246,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -2430,12 +2464,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -2447,6 +2492,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -2458,9 +2507,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',

View File

@@ -1129,7 +1129,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -1155,6 +1155,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -1166,6 +1170,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -1184,9 +1192,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -1401,12 +1410,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -1418,6 +1438,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -1429,9 +1453,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',

View File

@@ -2674,7 +2674,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -2700,6 +2700,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -2711,6 +2715,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -2728,9 +2736,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -2943,12 +2952,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -2960,6 +2980,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -2970,9 +2994,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',
@@ -4150,7 +4175,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -4176,6 +4201,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -4187,6 +4216,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -4204,9 +4237,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -4438,12 +4472,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -4455,6 +4500,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -4465,9 +4514,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',
@@ -5657,7 +5707,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -5683,6 +5733,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -5694,6 +5748,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -5711,9 +5769,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -5945,12 +6004,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -5962,6 +6032,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -5972,9 +6046,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',
@@ -7039,7 +7114,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -7065,6 +7140,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -7076,6 +7155,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -7093,9 +7176,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -7308,12 +7392,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -7325,6 +7420,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -7335,9 +7434,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',
@@ -8557,7 +8657,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -8583,6 +8683,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -8594,6 +8698,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -8611,9 +8719,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -8845,12 +8954,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -8862,6 +8982,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -8872,9 +8996,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',
@@ -9984,7 +10109,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -10010,6 +10135,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -10021,6 +10150,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -10038,9 +10171,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -10253,12 +10387,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -10270,6 +10415,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -10280,9 +10429,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',
@@ -11410,7 +11560,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -11436,6 +11586,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -11447,6 +11601,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -11464,9 +11622,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -11709,12 +11868,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -11726,6 +11896,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -11736,9 +11910,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',
@@ -12878,7 +13053,7 @@
'description': '''
Allowance for errors made by LLM.
Here we add an `error` key to surface errors made during generation
Here we add an ``error`` key to surface errors made during generation
(e.g., invalid JSON arguments.)
''',
'properties': dict({
@@ -12904,6 +13079,10 @@
]),
'title': 'Error',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -12915,6 +13094,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'anyOf': list([
dict({
@@ -12932,9 +13115,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
'error',
]),
'title': 'InvalidToolCall',
@@ -13166,12 +13350,23 @@
This represents a request to call the tool named "foo" with arguments {"a": 1}
and an identifier of "123".
.. note::
``create_tool_call`` may also be used as a factory to create a
``ToolCall``. Benefits include:
* Automatic ID generation (when not provided)
* Required arguments strictly validated at creation time
''',
'properties': dict({
'args': dict({
'title': 'Args',
'type': 'object',
}),
'extras': dict({
'title': 'Extras',
'type': 'object',
}),
'id': dict({
'anyOf': list([
dict({
@@ -13183,6 +13378,10 @@
]),
'title': 'Id',
}),
'index': dict({
'title': 'Index',
'type': 'integer',
}),
'name': dict({
'title': 'Name',
'type': 'string',
@@ -13193,9 +13392,10 @@
}),
}),
'required': list([
'type',
'id',
'name',
'args',
'id',
]),
'title': 'ToolCall',
'type': 'object',

View File

@@ -3,6 +3,7 @@ import uuid
from typing import Optional, Union
import pytest
from typing_extensions import get_args
from langchain_core.documents import Document
from langchain_core.load import dumpd, load
@@ -30,6 +31,7 @@ from langchain_core.messages import (
messages_from_dict,
messages_to_dict,
)
from langchain_core.messages.content_blocks import KNOWN_BLOCK_TYPES, ContentBlock
from langchain_core.messages.tool import invalid_tool_call as create_invalid_tool_call
from langchain_core.messages.tool import tool_call as create_tool_call
from langchain_core.messages.tool import tool_call_chunk as create_tool_call_chunk
@@ -178,21 +180,23 @@ def test_message_chunks() -> None:
assert AIMessageChunk(content="") + left == left
assert right + AIMessageChunk(content="") == right
default_id = "lc_run--abc123"
meaningful_id = "msg_def456"
# Test ID order of precedence
null_id = AIMessageChunk(content="", id=None)
default_id = AIMessageChunk(
content="", id="run-abc123"
null_id_chunk = AIMessageChunk(content="", id=None)
default_id_chunk = AIMessageChunk(
content="", id=default_id
) # LangChain-assigned run ID
meaningful_id = AIMessageChunk(content="", id="msg_def456") # provider-assigned ID
provider_chunk = AIMessageChunk(
content="", id=meaningful_id
) # provided ID (either by user or provider)
assert (null_id + default_id).id == "run-abc123"
assert (default_id + null_id).id == "run-abc123"
assert (null_id_chunk + default_id_chunk).id == default_id
assert (null_id_chunk + provider_chunk).id == meaningful_id
assert (null_id + meaningful_id).id == "msg_def456"
assert (meaningful_id + null_id).id == "msg_def456"
assert (default_id + meaningful_id).id == "msg_def456"
assert (meaningful_id + default_id).id == "msg_def456"
# Provider assigned IDs have highest precedence
assert (default_id_chunk + provider_chunk).id == meaningful_id
def test_chat_message_chunks() -> None:
@@ -207,7 +211,7 @@ def test_chat_message_chunks() -> None:
):
ChatMessageChunk(role="User", content="I am") + ChatMessageChunk(
role="Assistant", content=" indeed."
)
) # type: ignore[reportUnusedExpression, unused-ignore]
assert ChatMessageChunk(role="User", content="I am") + AIMessageChunk(
content=" indeed."
@@ -316,7 +320,7 @@ def test_function_message_chunks() -> None:
):
FunctionMessageChunk(name="hello", content="I am") + FunctionMessageChunk(
name="bye", content=" indeed."
)
) # type: ignore[reportUnusedExpression, unused-ignore]
def test_ai_message_chunks() -> None:
@@ -332,7 +336,7 @@ def test_ai_message_chunks() -> None:
):
AIMessageChunk(example=True, content="I am") + AIMessageChunk(
example=False, content=" indeed."
)
) # type: ignore[reportUnusedExpression, unused-ignore]
class TestGetBufferString(unittest.TestCase):
@@ -1038,12 +1042,13 @@ def test_tool_message_content() -> None:
ToolMessage(["foo"], tool_call_id="1")
ToolMessage([{"foo": "bar"}], tool_call_id="1")
assert ToolMessage(("a", "b", "c"), tool_call_id="1").content == ["a", "b", "c"] # type: ignore[arg-type]
assert ToolMessage(5, tool_call_id="1").content == "5" # type: ignore[arg-type]
assert ToolMessage(5.1, tool_call_id="1").content == "5.1" # type: ignore[arg-type]
assert ToolMessage({"foo": "bar"}, tool_call_id="1").content == "{'foo': 'bar'}" # type: ignore[arg-type]
# Ignoring since we're testing that tuples get converted to lists in `coerce_args`
assert ToolMessage(("a", "b", "c"), tool_call_id="1").content == ["a", "b", "c"] # type: ignore[call-overload]
assert ToolMessage(5, tool_call_id="1").content == "5" # type: ignore[call-overload]
assert ToolMessage(5.1, tool_call_id="1").content == "5.1" # type: ignore[call-overload]
assert ToolMessage({"foo": "bar"}, tool_call_id="1").content == "{'foo': 'bar'}" # type: ignore[call-overload]
assert (
ToolMessage(Document("foo"), tool_call_id="1").content == "page_content='foo'" # type: ignore[arg-type]
ToolMessage(Document("foo"), tool_call_id="1").content == "page_content='foo'" # type: ignore[call-overload]
)
@@ -1113,26 +1118,45 @@ def test_message_text() -> None:
def test_is_data_content_block() -> None:
# Test all DataContentBlock types with various data fields
# Image blocks
assert is_data_content_block({"type": "image", "url": "https://..."})
assert is_data_content_block(
{
"type": "image",
"source_type": "url",
"url": "https://...",
}
{"type": "image", "base64": "<base64 data>", "mime_type": "image/jpeg"}
)
# Video blocks
assert is_data_content_block({"type": "video", "url": "https://video.mp4"})
assert is_data_content_block(
{
"type": "image",
"source_type": "base64",
"data": "<base64 data>",
"mime_type": "image/jpeg",
}
{"type": "video", "base64": "<base64 video>", "mime_type": "video/mp4"}
)
assert is_data_content_block({"type": "video", "file_id": "vid_123"})
# Audio blocks
assert is_data_content_block({"type": "audio", "url": "https://audio.mp3"})
assert is_data_content_block(
{"type": "audio", "base64": "<base64 audio>", "mime_type": "audio/mp3"}
)
assert is_data_content_block({"type": "audio", "file_id": "aud_123"})
# Plain text blocks
assert is_data_content_block({"type": "text-plain", "text": "document content"})
assert is_data_content_block({"type": "text-plain", "url": "https://doc.txt"})
assert is_data_content_block({"type": "text-plain", "file_id": "txt_123"})
# File blocks
assert is_data_content_block({"type": "file", "url": "https://file.pdf"})
assert is_data_content_block(
{"type": "file", "base64": "<base64 file>", "mime_type": "application/pdf"}
)
assert is_data_content_block({"type": "file", "file_id": "file_123"})
# Blocks with additional metadata (should still be valid)
assert is_data_content_block(
{
"type": "image",
"source_type": "base64",
"data": "<base64 data>",
"base64": "<base64 data>",
"mime_type": "image/jpeg",
"cache_control": {"type": "ephemeral"},
}
@@ -1140,65 +1164,145 @@ def test_is_data_content_block() -> None:
assert is_data_content_block(
{
"type": "image",
"source_type": "base64",
"data": "<base64 data>",
"base64": "<base64 data>",
"mime_type": "image/jpeg",
"metadata": {"cache_control": {"type": "ephemeral"}},
}
)
assert not is_data_content_block(
assert is_data_content_block(
{
"type": "text",
"text": "foo",
"type": "image",
"base64": "<base64 data>",
"mime_type": "image/jpeg",
"extras": "hi",
}
)
# Invalid cases - wrong type
assert not is_data_content_block({"type": "text", "text": "foo"})
assert not is_data_content_block(
{
"type": "image_url",
"image_url": {"url": "https://..."},
}
)
assert not is_data_content_block(
{
"type": "image",
"source_type": "base64",
}
)
assert not is_data_content_block(
{
"type": "image",
"source": "<base64 data>",
}
} # This is OpenAI Chat Completions
)
assert not is_data_content_block({"type": "tool_call", "name": "func", "args": {}})
assert not is_data_content_block({"type": "invalid", "url": "something"})
# Invalid cases - valid type but no data or `source_type` fields
assert not is_data_content_block({"type": "image"})
assert not is_data_content_block({"type": "video", "mime_type": "video/mp4"})
assert not is_data_content_block({"type": "audio", "extras": {"key": "value"}})
# Invalid cases - valid type but wrong data field name
assert not is_data_content_block({"type": "image", "source": "<base64 data>"})
assert not is_data_content_block({"type": "video", "data": "video_data"})
# Edge cases - empty or missing values
assert not is_data_content_block({})
assert not is_data_content_block({"url": "https://..."}) # missing type
def test_convert_to_openai_image_block() -> None:
input_block = {
"type": "image",
"source_type": "url",
"url": "https://...",
"cache_control": {"type": "ephemeral"},
}
expected = {
"type": "image_url",
"image_url": {"url": "https://..."},
}
result = convert_to_openai_image_block(input_block)
assert result == expected
input_block = {
"type": "image",
"source_type": "base64",
"data": "<base64 data>",
"mime_type": "image/jpeg",
"cache_control": {"type": "ephemeral"},
}
expected = {
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,<base64 data>",
for input_block in [
{
"type": "image",
"url": "https://...",
"cache_control": {"type": "ephemeral"},
},
{
"type": "image",
"source_type": "url",
"url": "https://...",
"cache_control": {"type": "ephemeral"},
},
]:
expected = {
"type": "image_url",
"image_url": {"url": "https://..."},
}
result = convert_to_openai_image_block(input_block)
assert result == expected
for input_block in [
{
"type": "image",
"base64": "<base64 data>",
"mime_type": "image/jpeg",
"cache_control": {"type": "ephemeral"},
},
{
"type": "image",
"source_type": "base64",
"data": "<base64 data>",
"mime_type": "image/jpeg",
"cache_control": {"type": "ephemeral"},
},
]:
expected = {
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,<base64 data>",
},
}
result = convert_to_openai_image_block(input_block)
assert result == expected
def test_known_block_types() -> None:
expected = {
bt
for bt in get_args(ContentBlock)
for bt in get_args(bt.__annotations__["type"])
}
result = convert_to_openai_image_block(input_block)
assert result == expected
# Normalize any Literal[...] types in block types to their string values.
# This ensures all entries are plain strings, not Literal objects.
expected = {
t
if isinstance(t, str)
else t.__args__[0]
if hasattr(t, "__args__") and len(t.__args__) == 1
else t
for t in expected
}
assert expected == KNOWN_BLOCK_TYPES
def test_typed_init() -> None:
ai_message = AIMessage(content_blocks=[{"type": "text", "text": "Hello"}])
assert ai_message.content == [{"type": "text", "text": "Hello"}]
assert ai_message.content_blocks == ai_message.content
human_message = HumanMessage(content_blocks=[{"type": "text", "text": "Hello"}])
assert human_message.content == [{"type": "text", "text": "Hello"}]
assert human_message.content_blocks == human_message.content
system_message = SystemMessage(content_blocks=[{"type": "text", "text": "Hello"}])
assert system_message.content == [{"type": "text", "text": "Hello"}]
assert system_message.content_blocks == system_message.content
tool_message = ToolMessage(
content_blocks=[{"type": "text", "text": "Hello"}],
tool_call_id="abc123",
)
assert tool_message.content == [{"type": "text", "text": "Hello"}]
assert tool_message.content_blocks == tool_message.content
for message_class in [AIMessage, HumanMessage, SystemMessage]:
message = message_class("Hello")
assert message.content == "Hello"
assert message.content_blocks == [{"type": "text", "text": "Hello"}]
message = message_class(content="Hello")
assert message.content == "Hello"
assert message.content_blocks == [{"type": "text", "text": "Hello"}]
# Test we get type errors for malformed blocks (type checker will complain if
# below type-ignores are unused).
_ = AIMessage(content_blocks=[{"type": "text", "bad": "Hello"}]) # type: ignore[list-item]
_ = HumanMessage(content_blocks=[{"type": "text", "bad": "Hello"}]) # type: ignore[list-item]
_ = SystemMessage(content_blocks=[{"type": "text", "bad": "Hello"}]) # type: ignore[list-item]
_ = ToolMessage(
content_blocks=[{"type": "text", "bad": "Hello"}], # type: ignore[list-item]
tool_call_id="abc123",
)

View File

@@ -2281,7 +2281,7 @@ def test_tool_injected_tool_call_id() -> None:
@tool
def foo(x: int, tool_call_id: Annotated[str, InjectedToolCallId]) -> ToolMessage:
"""Foo."""
return ToolMessage(x, tool_call_id=tool_call_id) # type: ignore[arg-type]
return ToolMessage(x, tool_call_id=tool_call_id) # type: ignore[call-overload]
assert foo.invoke(
{
@@ -2290,7 +2290,7 @@ def test_tool_injected_tool_call_id() -> None:
"name": "foo",
"id": "bar",
}
) == ToolMessage(0, tool_call_id="bar") # type: ignore[arg-type]
) == ToolMessage(0, tool_call_id="bar") # type: ignore[call-overload]
with pytest.raises(
ValueError,
@@ -2302,7 +2302,7 @@ def test_tool_injected_tool_call_id() -> None:
@tool
def foo2(x: int, tool_call_id: Annotated[str, InjectedToolCallId()]) -> ToolMessage:
"""Foo."""
return ToolMessage(x, tool_call_id=tool_call_id) # type: ignore[arg-type]
return ToolMessage(x, tool_call_id=tool_call_id) # type: ignore[call-overload]
assert foo2.invoke(
{
@@ -2311,14 +2311,14 @@ def test_tool_injected_tool_call_id() -> None:
"name": "foo",
"id": "bar",
}
) == ToolMessage(0, tool_call_id="bar") # type: ignore[arg-type]
) == ToolMessage(0, tool_call_id="bar") # type: ignore[call-overload]
def test_tool_uninjected_tool_call_id() -> None:
@tool
def foo(x: int, tool_call_id: str) -> ToolMessage:
"""Foo."""
return ToolMessage(x, tool_call_id=tool_call_id) # type: ignore[arg-type]
return ToolMessage(x, tool_call_id=tool_call_id) # type: ignore[call-overload]
with pytest.raises(ValueError, match="1 validation error for foo"):
foo.invoke({"type": "tool_call", "args": {"x": 0}, "name": "foo", "id": "bar"})
@@ -2330,7 +2330,7 @@ def test_tool_uninjected_tool_call_id() -> None:
"name": "foo",
"id": "bar",
}
) == ToolMessage(0, tool_call_id="zap") # type: ignore[arg-type]
) == ToolMessage(0, tool_call_id="zap") # type: ignore[call-overload]
def test_tool_return_output_mixin() -> None:

View File

@@ -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"`` and
``"responses/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
@@ -57,18 +61,20 @@ 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.
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, Iterator
from typing import Any, Literal, Optional, Union, cast
from langchain_core.messages import AIMessage
from langchain_core.messages import AIMessage, AIMessageChunk, is_data_content_block
from langchain_core.messages import content_blocks as types
_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:
@@ -253,3 +259,497 @@ 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:
message.content = [{"type": "text", "text": message.content}]
else:
message.content = []
for tool_call in message.tool_calls:
message.content.append(cast(dict, tool_call))
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:
"""Mutate a Chat Completions chunk to v1 format."""
if isinstance(chunk.content, str):
if chunk.content:
chunk.content = [{"type": "text", "text": chunk.content}]
else:
chunk.content = []
for tool_call_chunk in chunk.tool_call_chunks:
chunk.content.append(cast(dict, tool_call_chunk))
if "tool_calls" in chunk.additional_kwargs:
_ = chunk.additional_kwargs.pop("tool_calls")
if "token_usage" in chunk.response_metadata:
_ = chunk.response_metadata.pop("token_usage")
return chunk
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]) -> types.Annotation:
annotation_type = annotation.get("type")
if annotation_type == "url_citation":
known_fields = {
"type",
"url",
"title",
"cited_text",
"start_index",
"end_index",
}
url_citation = cast(types.Citation, {})
for field in ("end_index", "start_index", "title"):
if field in annotation:
url_citation[field] = annotation[field]
url_citation["type"] = "citation"
url_citation["url"] = annotation["url"]
for field in annotation:
if field not in known_fields:
if "extras" not in url_citation:
url_citation["extras"] = {}
url_citation["extras"][field] = annotation[field]
return url_citation
elif annotation_type == "file_citation":
known_fields = {"type", "title", "cited_text", "start_index", "end_index"}
document_citation: types.Citation = {"type": "citation"}
if "filename" in annotation:
document_citation["title"] = annotation.pop("filename")
for field in annotation:
if field not in known_fields:
if "extras" not in document_citation:
document_citation["extras"] = {}
document_citation["extras"][field] = annotation[field]
return document_citation
# TODO: standardise container_file_citation?
else:
non_standard_annotation: types.NonStandardAnnotation = {
"type": "non_standard_annotation",
"value": annotation,
}
return non_standard_annotation
def _explode_reasoning(block: dict[str, Any]) -> Iterable[types.ReasoningContentBlock]:
if "summary" not in block:
yield cast(types.ReasoningContentBlock, block)
return
known_fields = {"type", "reasoning", "id", "index"}
unknown_fields = [
field for field in block if field != "summary" and field not in known_fields
]
if unknown_fields:
block["extras"] = {}
for field in unknown_fields:
block["extras"][field] = block.pop(field)
if not block["summary"]:
_ = block.pop("summary", None)
yield cast(types.ReasoningContentBlock, block)
return
# Common part for every exploded line, except 'summary'
common = {k: v for k, v in block.items() if k in known_fields}
# Optional keys that must appear only in the first exploded item
first_only = block.pop("extras", None)
for idx, part in enumerate(block["summary"]):
new_block = dict(common)
new_block["reasoning"] = part.get("text", "")
if idx == 0 and first_only:
new_block.update(first_only)
yield cast(types.ReasoningContentBlock, new_block)
def _convert_to_v1_from_responses(
content: list[dict[str, Any]],
tool_calls: Optional[list[types.ToolCall]] = None,
invalid_tool_calls: Optional[list[types.InvalidToolCall]] = None,
) -> list[types.ContentBlock]:
"""Mutate a Responses message to v1 format."""
def _iter_blocks() -> Iterable[types.ContentBlock]:
for block in content:
if not isinstance(block, dict):
continue
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 cast(types.TextContentBlock, block)
elif block_type == "reasoning":
yield from _explode_reasoning(block)
elif block_type == "image_generation_call" and (
result := block.get("result")
):
new_block = {"type": "image", "base64": result}
if output_format := block.get("output_format"):
new_block["mime_type"] = f"image/{output_format}"
if "id" in block:
new_block["id"] = block["id"]
if "index" in block:
new_block["index"] = block["index"]
for extra_key in (
"status",
"background",
"output_format",
"quality",
"revised_prompt",
"size",
):
if extra_key in block:
if "extras" not in new_block:
new_block["extras"] = {}
new_block["extras"][extra_key] = block[extra_key]
yield cast(types.ImageContentBlock, new_block)
elif block_type == "function_call":
tool_call_block: Optional[
Union[types.ToolCall, types.InvalidToolCall]
] = None
call_id = block.get("call_id", "")
if call_id:
for tool_call in tool_calls or []:
if tool_call.get("id") == call_id:
tool_call_block = cast(types.ToolCall, tool_call.copy())
break
else:
for invalid_tool_call in invalid_tool_calls or []:
if invalid_tool_call.get("id") == call_id:
tool_call_block = cast(
types.InvalidToolCall, invalid_tool_call.copy()
)
break
if tool_call_block:
if "id" in block:
if "extras" not in tool_call_block:
tool_call_block["extras"] = {}
tool_call_block["extras"]["item_id"] = block["id"] # type: ignore[typeddict-item]
if "index" in block:
tool_call_block["index"] = block["index"]
yield tool_call_block
elif block_type == "web_search_call":
web_search_call = {"type": "web_search_call", "id": block["id"]}
if "index" in block:
web_search_call["index"] = block["index"]
if (
"action" in block
and isinstance(block["action"], dict)
and block["action"].get("type") == "search"
and "query" in block["action"]
):
web_search_call["query"] = block["action"]["query"]
for key in block:
if key not in ("type", "id"):
web_search_call[key] = block[key]
web_search_result = {"type": "web_search_result", "id": block["id"]}
if "index" in block:
web_search_result["index"] = block["index"] + 1
yield cast(types.WebSearchCall, web_search_call)
yield cast(types.WebSearchResult, web_search_result)
elif block_type == "code_interpreter_call":
code_interpreter_call = {
"type": "code_interpreter_call",
"id": block["id"],
}
if "code" in block:
code_interpreter_call["code"] = block["code"]
if "container_id" in block:
code_interpreter_call["container_id"] = block["container_id"]
if "index" in block:
code_interpreter_call["index"] = block["index"]
code_interpreter_result = {
"type": "code_interpreter_result",
"id": block["id"],
}
if "outputs" in block:
code_interpreter_result["outputs"] = block["outputs"]
for output in block["outputs"]:
if (
isinstance(output, dict)
and (output_type := output.get("type"))
and output_type == "logs"
):
if "output" not in code_interpreter_result:
code_interpreter_result["output"] = []
code_interpreter_result["output"].append(
{
"type": "code_interpreter_output",
"stdout": output.get("logs", ""),
}
)
if "status" in block:
code_interpreter_result["status"] = block["status"]
if "index" in block:
code_interpreter_result["index"] = block["index"] + 1
yield cast(types.CodeInterpreterCall, code_interpreter_call)
yield cast(types.CodeInterpreterResult, code_interpreter_result)
else:
new_block = {"type": "non_standard", "value": block}
if "index" in new_block["value"]:
new_block["index"] = new_block["value"].pop("index")
yield cast(types.NonStandardContentBlock, new_block)
return list(_iter_blocks())
def _convert_annotation_from_v1(annotation: types.Annotation) -> dict[str, Any]:
if annotation["type"] == "citation":
new_ann: dict[str, Any] = {}
for field in ("end_index", "start_index"):
if field in annotation:
new_ann[field] = annotation[field]
if "url" in annotation:
# URL citation
if "title" in annotation:
new_ann["title"] = annotation["title"]
new_ann["type"] = "url_citation"
new_ann["url"] = annotation["url"]
else:
# Document citation
new_ann["type"] = "file_citation"
if "title" in annotation:
new_ann["filename"] = annotation["title"]
if extra_fields := annotation.get("extras"):
for field, value in extra_fields.items():
new_ann[field] = value
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]
# Skip non-reasoning blocks or blocks already in Responses format
if block.get("type") != "reasoning" or "summary" in block:
yield dict(block)
i += 1
continue
elif "reasoning" not in block and "summary" not in block:
# {"type": "reasoning", "id": "rs_..."}
oai_format = {**block, "summary": []}
if "extras" in oai_format:
oai_format.update(oai_format.pop("extras"))
oai_format["type"] = oai_format.pop("type", "reasoning")
if "encrypted_content" in oai_format:
oai_format["encrypted_content"] = oai_format.pop("encrypted_content")
yield oai_format
i += 1
continue
else:
pass
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"}
if "extras" in common:
common.update(common.pop("extras"))
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
merged["type"] = merged.pop("type", "reasoning")
yield merged
def _consolidate_calls(
items: Iterable[dict[str, Any]],
call_name: Literal["web_search_call", "code_interpreter_call"],
result_name: Literal["web_search_result", "code_interpreter_result"],
) -> Iterator[dict[str, Any]]:
"""
Generator that walks through *items* and, whenever it meets the pair
{"type": "web_search_call", "id": X, ...}
{"type": "web_search_result", "id": X}
merges them into
{"id": X,
"action": …,
"status": …,
"type": "web_search_call"}
keeping every other element untouched.
"""
items = iter(items) # make sure we have a true iterator
for current in items:
# Only a call can start a pair worth collapsing
if current.get("type") != call_name:
yield current
continue
try:
nxt = next(items) # look-ahead one element
except StopIteration: # no “result” just yield the call back
yield current
break
# If this really is the matching “result” collapse
if nxt.get("type") == result_name and nxt.get("id") == current.get("id"):
if call_name == "web_search_call":
collapsed = {"id": current["id"]}
if "action" in current:
collapsed["action"] = current["action"]
collapsed["status"] = current["status"]
collapsed["type"] = "web_search_call"
if call_name == "code_interpreter_call":
collapsed = {"id": current["id"]}
for key in ("code", "container_id"):
if key in current:
collapsed[key] = current[key]
for key in ("outputs", "status"):
if key in nxt:
collapsed[key] = nxt[key]
collapsed["type"] = "code_interpreter_call"
yield collapsed
else:
# Not a matching pair emit both, in original order
yield current
yield nxt
def _convert_from_v1_to_responses(
content: list[types.ContentBlock], tool_calls: list[types.ToolCall]
) -> list[dict[str, Any]]:
new_content: list = []
for block in content:
if block["type"] == "text" and "annotations" in block:
# Need a copy because were 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 "extras" in block and "item_id" in block["extras"]:
new_block["id"] = block["extras"]["item_id"]
if "name" in block:
new_block["name"] = block["name"]
if "extras" in block and "arguments" in block["extras"]:
new_block["arguments"] = block["extras"]["arguments"]
if any(key not in block for key in ("name", "arguments")):
matching_tool_calls = [
call for call in tool_calls if call["id"] == block["id"]
]
if matching_tool_calls:
tool_call = matching_tool_calls[0]
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(cast(dict, block))
and block["type"] == "image"
and "base64" in block
and isinstance(block.get("id"), str)
and block["id"].startswith("ig_")
):
new_block = {"type": "image_generation_call", "result": block["base64"]}
for extra_key in ("id", "status"):
if extra_key in block:
new_block[extra_key] = block[extra_key] # type: ignore[typeddict-item]
elif extra_key in block.get("extras", {}):
new_block[extra_key] = block["extras"][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)
new_content = list(_implode_reasoning_blocks(new_content))
new_content = list(
_consolidate_calls(new_content, "web_search_call", "web_search_result")
)
new_content = list(
_consolidate_calls(
new_content, "code_interpreter_call", "code_interpreter_result"
)
)
return new_content

View File

@@ -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:
@@ -202,7 +207,7 @@ def _convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage:
return ChatMessage(content=_dict.get("content", ""), role=role, id=id_) # type: ignore[arg-type]
def _format_message_content(content: Any) -> Any:
def _format_message_content(content: Any, responses_ai_msg: bool = False) -> Any:
"""Format message content."""
if content and isinstance(content, list):
formatted_content = []
@@ -214,7 +219,13 @@ def _format_message_content(content: Any) -> Any:
and block["type"] in ("tool_use", "thinking", "reasoning_content")
):
continue
elif isinstance(block, dict) and is_data_content_block(block):
elif (
isinstance(block, dict)
and is_data_content_block(block)
# Responses API messages handled separately in _compat (parsed into
# image generation calls)
and not responses_ai_msg
):
formatted_content.append(convert_to_openai_data_block(block))
# Anthropic image blocks
elif (
@@ -247,7 +258,9 @@ def _format_message_content(content: Any) -> Any:
return formatted_content
def _convert_message_to_dict(message: BaseMessage) -> dict:
def _convert_message_to_dict(
message: BaseMessage, responses_ai_msg: bool = False
) -> dict:
"""Convert a LangChain message to a dictionary.
Args:
@@ -256,7 +269,11 @@ def _convert_message_to_dict(message: BaseMessage) -> dict:
Returns:
The dictionary.
"""
message_dict: dict[str, Any] = {"content": _format_message_content(message.content)}
message_dict: dict[str, Any] = {
"content": _format_message_content(
message.content, responses_ai_msg=responses_ai_msg
)
}
if (name := message.name or message.additional_kwargs.get("name")) is not None:
message_dict["name"] = name
@@ -291,15 +308,25 @@ def _convert_message_to_dict(message: BaseMessage) -> dict:
if "function_call" in message_dict or "tool_calls" in message_dict:
message_dict["content"] = message_dict["content"] or None
if "audio" in message.additional_kwargs:
# openai doesn't support passing the data back - only the id
# https://platform.openai.com/docs/guides/audio/multi-turn-conversations
audio: Optional[dict[str, Any]] = None
for block in message.content:
if (
isinstance(block, dict)
and block.get("type") == "audio"
and (id_ := block.get("id"))
and not responses_ai_msg
):
# openai doesn't support passing the data back - only the id
# https://platform.openai.com/docs/guides/audio/multi-turn-conversations
audio = {"id": id_}
if not audio and "audio" in message.additional_kwargs:
raw_audio = message.additional_kwargs["audio"]
audio = (
{"id": message.additional_kwargs["audio"]["id"]}
if "id" in raw_audio
else raw_audio
)
if audio:
message_dict["audio"] = audio
elif isinstance(message, SystemMessage):
message_dict["role"] = message.additional_kwargs.get(
@@ -681,7 +708,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
@@ -692,8 +719,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
Currently only impacts the Responses API. ``output_version='v1'`` is
recommended.
.. versionadded:: 0.3.25
@@ -896,6 +924,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]
@@ -923,6 +955,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
)
@@ -1216,7 +1262,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(
@@ -1265,6 +1316,7 @@ class BaseChatOpenAI(BaseChatModel):
generations.append(gen)
llm_output = {
"token_usage": token_usage,
"model_provider": "openai",
"model_name": response_dict.get("model", self.model_name),
"system_fingerprint": response_dict.get("system_fingerprint", ""),
}
@@ -1280,7 +1332,24 @@ class BaseChatOpenAI(BaseChatModel):
if hasattr(message, "parsed"):
generations[0].message.additional_kwargs["parsed"] = message.parsed
if hasattr(message, "refusal"):
generations[0].message.additional_kwargs["refusal"] = message.refusal
if self.output_version in ("v0", "responses/v1"):
generations[0].message.additional_kwargs["refusal"] = (
message.refusal
)
elif self.output_version == "v1":
if isinstance(generations[0].message.content, list):
generations[0].message.content.append(
{
"type": "non_standard",
"value": {"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)
@@ -3384,6 +3453,20 @@ def _oai_structured_outputs_parser(
return parsed
elif ai_msg.additional_kwargs.get("refusal"):
raise OpenAIRefusalError(ai_msg.additional_kwargs["refusal"])
elif any(
isinstance(block, dict)
and block.get("type") == "non_standard"
and "refusal" in block["value"]
for block in ai_msg.content
):
refusal = next(
block["value"]["refusal"]
for block in ai_msg.content
if isinstance(block, dict)
and block["type"] == "non_standard"
and "refusal" in block["value"]
)
raise OpenAIRefusalError(refusal)
elif ai_msg.tool_calls:
return None
else:
@@ -3500,7 +3583,7 @@ def _get_last_messages(
msg = messages[i]
if isinstance(msg, AIMessage):
response_id = msg.response_metadata.get("id")
if response_id:
if response_id and response_id.startswith("resp_"):
return messages[i + 1 :], response_id
else:
return messages, None
@@ -3609,23 +3692,45 @@ def _construct_responses_api_payload(
return payload
def _make_computer_call_output_from_message(message: ToolMessage) -> dict:
computer_call_output: dict = {
"call_id": message.tool_call_id,
"type": "computer_call_output",
}
def _make_computer_call_output_from_message(
message: ToolMessage,
) -> Optional[dict[str, Any]]:
computer_call_output: Optional[dict[str, Any]] = None
if isinstance(message.content, list):
# Use first input_image block
output = next(
block
for block in message.content
if cast(dict, block)["type"] == "input_image"
)
for block in message.content:
if (
message.additional_kwargs.get("type") == "computer_call_output"
and isinstance(block, dict)
and block.get("type") == "input_image"
):
# Use first input_image block
computer_call_output = {
"call_id": message.tool_call_id,
"type": "computer_call_output",
"output": block,
}
break
elif (
isinstance(block, dict)
and block.get("type") == "non_standard"
and block.get("value", {}).get("type") == "computer_call_output"
):
computer_call_output = block["value"]
break
else:
pass
else:
# string, assume image_url
output = {"type": "input_image", "image_url": message.content}
computer_call_output["output"] = output
if "acknowledged_safety_checks" in message.additional_kwargs:
if message.additional_kwargs.get("type") == "computer_call_output":
# string, assume image_url
computer_call_output = {
"call_id": message.tool_call_id,
"type": "computer_call_output",
"output": {"type": "input_image", "image_url": message.content},
}
if (
computer_call_output is not None
and "acknowledged_safety_checks" in message.additional_kwargs
):
computer_call_output["acknowledged_safety_checks"] = message.additional_kwargs[
"acknowledged_safety_checks"
]
@@ -3642,6 +3747,15 @@ def _make_custom_tool_output_from_message(message: ToolMessage) -> Optional[dict
"output": block.get("output") or "",
}
break
elif (
isinstance(block, dict)
and block.get("type") == "non_standard"
and block.get("value", {}).get("type") == "custom_tool_call_output"
):
custom_tool_output = block["value"]
break
else:
pass
return custom_tool_output
@@ -3666,20 +3780,33 @@ 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)
msg = _convert_message_to_dict(lc_msg)
msg = _convert_message_to_dict(lc_msg, responses_ai_msg=True)
if isinstance(msg.get("content"), list) and all(
isinstance(block, dict) for block in msg["content"]
):
msg["content"] = _convert_from_v1_to_responses(
msg["content"], lc_msg.tool_calls
)
else:
msg = _convert_message_to_dict(lc_msg)
# Get content from non-standard content blocks
if isinstance(msg["content"], list):
for i, block in enumerate(msg["content"]):
if isinstance(block, dict) and block.get("type") == "non_standard":
msg["content"][i] = block["value"]
# "name" parameter unsupported
if "name" in msg:
msg.pop("name")
if msg["role"] == "tool":
tool_output = msg["content"]
computer_call_output = _make_computer_call_output_from_message(
cast(ToolMessage, lc_msg)
)
custom_tool_output = _make_custom_tool_output_from_message(lc_msg) # type: ignore[arg-type]
if custom_tool_output:
input_.append(custom_tool_output)
elif lc_msg.additional_kwargs.get("type") == "computer_call_output":
computer_call_output = _make_computer_call_output_from_message(
cast(ToolMessage, lc_msg)
)
if computer_call_output:
input_.append(computer_call_output)
elif custom_tool_output:
input_.append(custom_tool_output)
else:
if not isinstance(tool_output, str):
tool_output = _stringify(tool_output)
@@ -3828,7 +3955,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:
@@ -3855,6 +3982,7 @@ def _construct_lc_result_from_responses_api(
if metadata:
response_metadata.update(metadata)
# for compatibility with chat completion calls.
response_metadata["model_provider"] = "openai"
response_metadata["model_name"] = response_metadata.get("model")
if response.usage:
usage_metadata = _create_usage_metadata_responses(response.usage.model_dump())
@@ -3966,6 +4094,30 @@ def _construct_lc_result_from_responses_api(
additional_kwargs["parsed"] = parsed
except json.JSONDecodeError:
pass
if output_version == "v1":
content_blocks = _convert_to_v1_from_responses(content_blocks)
if response.tools and any(
tool.type == "image_generation" for tool in response.tools
):
# Get mime_time from tool definition and add to image generations
# if missing (primarily for tracing purposes).
image_generation_call = next(
tool for tool in response.tools if tool.type == "image_generation"
)
if image_generation_call.output_format:
mime_type = f"image/{image_generation_call.output_format}"
for content_block in content_blocks:
# OK to mutate output message
if (
isinstance(content_block, dict)
and content_block.get("type") == "image"
and "base64" in content_block
and "mime_type" not in block
):
block["mime_type"] = mime_type
message = AIMessage(
content=content_blocks,
id=response.id,
@@ -3990,7 +4142,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.
@@ -4056,9 +4208,29 @@ def _convert_responses_chunk_to_generation_chunk(
annotation = chunk.annotation
else:
annotation = chunk.annotation.model_dump(exclude_none=True, mode="json")
content.append({"annotations": [annotation], "index": current_index})
if output_version == "v1":
content.append(
{
"type": "text",
"text": "",
"annotations": [annotation],
"index": current_index,
}
)
else:
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
@@ -4151,21 +4323,35 @@ 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:
# v1
block: dict = {"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
@@ -4187,6 +4373,16 @@ def _convert_responses_chunk_to_generation_chunk(
else:
return current_index, current_output_index, current_sub_index, None
if output_version == "v1":
content = cast(list[dict], _convert_to_v1_from_responses(content))
for content_block in content:
if (
isinstance(content_block, dict)
and content_block.get("index", -1) > current_index
):
# blocks were added for v1
current_index = content_block["index"]
message = AIMessageChunk(
content=content, # type: ignore[arg-type]
tool_call_chunks=tool_call_chunks,

View File

@@ -22,14 +22,14 @@ from langchain_openai import ChatOpenAI, custom_tool
MODEL_NAME = "gpt-4o-mini"
def _check_response(response: Optional[BaseMessage]) -> None:
def _check_response(response: Optional[BaseMessage], output_version: str) -> None:
assert isinstance(response, AIMessage)
assert isinstance(response.content, list)
for block in response.content:
assert isinstance(block, dict)
if block["type"] == "text":
assert isinstance(block["text"], str)
for annotation in block["annotations"]:
assert isinstance(block["text"], str) # type: ignore[typeddict-item]
for annotation in block["annotations"]: # type: ignore[typeddict-item]
if annotation["type"] == "file_citation":
assert all(
key in annotation
@@ -40,8 +40,12 @@ def _check_response(response: Optional[BaseMessage]) -> None:
key in annotation
for key in ["end_index", "start_index", "title", "type", "url"]
)
text_content = response.text()
elif annotation["type"] == "citation":
assert all(key in annotation for key in ["title", "type"])
if "url" in annotation:
assert "start_index" in annotation
assert "end_index" in annotation
text_content = response.text() # type: ignore[operator,misc]
assert isinstance(text_content, str)
assert text_content
assert response.usage_metadata
@@ -49,68 +53,62 @@ def _check_response(response: Optional[BaseMessage]) -> None:
assert response.usage_metadata["output_tokens"] > 0
assert response.usage_metadata["total_tokens"] > 0
assert response.response_metadata["model_name"]
assert response.response_metadata["service_tier"]
assert response.response_metadata["service_tier"] # type: ignore[typeddict-item]
@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["responses/v1", "v1"]) -> None:
llm = ChatOpenAI(model=MODEL_NAME, output_version=output_version) # type: ignore[assignment]
first_response = llm.invoke(
"What was a positive news story from today?",
tools=[{"type": "web_search_preview"}],
)
_check_response(first_response)
_check_response(first_response, output_version)
# Test streaming
full: Optional[BaseMessageChunk] = None
full: Optional[BaseMessageChunk] = None # type: ignore[no-redef]
for chunk in llm.stream(
"What was a positive news story from today?",
tools=[{"type": "web_search_preview"}],
):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
_check_response(full)
_check_response(full, output_version)
# Use OpenAI's stateful API
response = llm.invoke(
"what about a negative one",
tools=[{"type": "web_search_preview"}],
previous_response_id=first_response.response_metadata["id"],
previous_response_id=first_response.response_metadata["id"], # type: ignore[typeddict-item]
)
_check_response(response)
_check_response(response, output_version)
# Manually pass in chat history
response = llm.invoke(
[
{
"role": "user",
"content": [
{
"type": "text",
"text": "What was a positive news story from today?",
}
],
},
{"role": "user", "content": "What was a positive news story from today?"},
first_response,
{
"role": "user",
"content": [{"type": "text", "text": "what about a negative one"}],
},
{"role": "user", "content": "what about a negative one"},
],
tools=[{"type": "web_search_preview"}],
)
_check_response(response)
_check_response(response, output_version)
# Bind tool
response = llm.bind_tools([{"type": "web_search_preview"}]).invoke(
"What was a positive news story from today?"
)
_check_response(response)
_check_response(response, output_version)
for msg in [first_response, full, response]:
assert isinstance(msg, AIMessage)
assert msg is not None
block_types = [block["type"] for block in msg.content] # type: ignore[index]
assert block_types == ["web_search_call", "text"]
if output_version == "responses/v1":
assert block_types == ["web_search_call", "text"]
else:
assert block_types == ["web_search_call", "web_search_result", "text"]
@pytest.mark.flaky(retries=3, delay=1)
@@ -120,7 +118,7 @@ async def test_web_search_async() -> None:
"What was a positive news story from today?",
tools=[{"type": "web_search_preview"}],
)
_check_response(response)
_check_response(response, "v0")
assert response.response_metadata["status"]
# Test streaming
@@ -132,7 +130,7 @@ async def test_web_search_async() -> None:
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
_check_response(full)
_check_response(full, "v0")
for msg in [response, full]:
assert msg.additional_kwargs["tool_outputs"]
@@ -141,13 +139,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
@@ -163,7 +163,7 @@ def test_function_calling() -> None:
assert set(full.tool_calls[0]["args"]) == {"x", "y"}
response = bound_llm.invoke("What was a positive news story from today?")
_check_response(response)
_check_response(response, output_version)
class Foo(BaseModel):
@@ -174,8 +174,15 @@ class FooDict(TypedDict):
response: str
def test_parsed_pydantic_schema() -> None:
llm = ChatOpenAI(model=MODEL_NAME, use_responses_api=True)
@pytest.mark.default_cassette("test_parsed_pydantic_schema.yaml.gz")
@pytest.mark.vcr
@pytest.mark.parametrize("output_version", ["v0", "responses/v1", "v1"])
def test_parsed_pydantic_schema(
output_version: Literal["v0", "responses/v1", "v1"],
) -> None:
llm = ChatOpenAI(
model=MODEL_NAME, use_responses_api=True, output_version=output_version
)
response = llm.invoke("how are ya", response_format=Foo)
parsed = Foo(**json.loads(response.text()))
assert parsed == response.additional_kwargs["parsed"]
@@ -297,8 +304,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
)
@@ -358,27 +365,32 @@ def test_computer_calls() -> None:
def test_file_search() -> None:
pytest.skip() # TODO: set up infra
llm = ChatOpenAI(model=MODEL_NAME)
llm = ChatOpenAI(model=MODEL_NAME, use_responses_api=True)
tool = {
"type": "file_search",
"vector_store_ids": [os.environ["OPENAI_VECTOR_STORE_ID"]],
}
response = llm.invoke("What is deep research by OpenAI?", tools=[tool])
_check_response(response)
input_message = {"role": "user", "content": "What is deep research by OpenAI?"}
response = llm.invoke([input_message], tools=[tool])
_check_response(response, "v0")
full: Optional[BaseMessageChunk] = None
for chunk in llm.stream("What is deep research by OpenAI?", tools=[tool]):
for chunk in llm.stream([input_message], tools=[tool]):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
_check_response(full)
_check_response(full, "v0")
next_message = {"role": "user", "content": "Thank you."}
_ = llm.invoke([input_message, full, next_message])
@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,7 +410,14 @@ 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
@@ -407,13 +426,25 @@ def test_stream_reasoning_summary(
if isinstance(reasoning, str):
reasoning = json.loads(reasoning)
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_blocks:
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."}
@@ -421,9 +452,13 @@ def test_stream_reasoning_summary(
assert isinstance(response_2, AIMessage)
@pytest.mark.default_cassette("test_code_interpreter.yaml.gz")
@pytest.mark.vcr
def test_code_interpreter() -> None:
llm = ChatOpenAI(model="o4-mini", use_responses_api=True)
@pytest.mark.parametrize("output_version", ["v0", "responses/v1", "v1"])
def test_code_interpreter(output_version: Literal["v0", "responses/v1", "v1"]) -> None:
llm = ChatOpenAI(
model="o4-mini", use_responses_api=True, output_version=output_version
)
llm_with_tools = llm.bind_tools(
[{"type": "code_interpreter", "container": {"type": "auto"}}]
)
@@ -432,15 +467,40 @@ def test_code_interpreter() -> None:
"content": "Write and run code to answer the question: what is 3^3?",
}
response = llm_with_tools.invoke([input_message])
_check_response(response)
tool_outputs = response.additional_kwargs["tool_outputs"]
assert tool_outputs
assert any(output["type"] == "code_interpreter_call" for output in tool_outputs)
assert isinstance(response, AIMessage)
_check_response(response, output_version)
if output_version == "v0":
tool_outputs = [
item
for item in response.additional_kwargs["tool_outputs"]
if item["type"] == "code_interpreter_call"
]
assert len(tool_outputs) == 1
elif output_version == "responses/v1":
tool_outputs = [
item
for item in response.content
if isinstance(item, dict) and item["type"] == "code_interpreter_call"
]
assert len(tool_outputs) == 1
else:
# v1
tool_outputs = [
item
for item in response.content_blocks
if item["type"] == "code_interpreter_call"
]
code_interpreter_result = next(
item
for item in response.content_blocks
if item["type"] == "code_interpreter_result"
)
assert tool_outputs
assert code_interpreter_result
assert len(tool_outputs) == 1
# Test streaming
# Use same container
tool_outputs = response.additional_kwargs["tool_outputs"]
assert len(tool_outputs) == 1
container_id = tool_outputs[0]["container_id"]
llm_with_tools = llm.bind_tools(
[{"type": "code_interpreter", "container": container_id}]
@@ -451,9 +511,34 @@ def test_code_interpreter() -> None:
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
tool_outputs = full.additional_kwargs["tool_outputs"]
assert tool_outputs
assert any(output["type"] == "code_interpreter_call" for output in tool_outputs)
if output_version == "v0":
tool_outputs = [
item
for item in response.additional_kwargs["tool_outputs"]
if item["type"] == "code_interpreter_call"
]
assert tool_outputs
elif output_version == "responses/v1":
tool_outputs = [
item
for item in response.content
if isinstance(item, dict) and item["type"] == "code_interpreter_call"
]
assert tool_outputs
else:
# v1
code_interpreter_call = next(
item
for item in full.content_blocks
if item["type"] == "code_interpreter_call"
)
code_interpreter_result = next(
item
for item in full.content_blocks
if item["type"] == "code_interpreter_result"
)
assert code_interpreter_call
assert code_interpreter_result
# Test we can pass back in
next_message = {"role": "user", "content": "Please add more comments to the code."}
@@ -548,10 +633,69 @@ def test_mcp_builtin_zdr() -> None:
_ = llm_with_tools.invoke([input_message, full, approval_message])
@pytest.mark.vcr()
def test_image_generation_streaming() -> None:
@pytest.mark.default_cassette("test_mcp_builtin_zdr.yaml.gz")
@pytest.mark.vcr
def test_mcp_builtin_zdr_v1() -> None:
llm = ChatOpenAI(
model="o4-mini",
output_version="v1",
store=False,
include=["reasoning.encrypted_content"],
)
llm_with_tools = llm.bind_tools(
[
{
"type": "mcp",
"server_label": "deepwiki",
"server_url": "https://mcp.deepwiki.com/mcp",
"require_approval": {"always": {"tool_names": ["read_wiki_structure"]}},
}
]
)
input_message = {
"role": "user",
"content": (
"What transport protocols does the 2025-03-26 version of the MCP spec "
"support?"
),
}
full: Optional[BaseMessageChunk] = None
for chunk in llm_with_tools.stream([input_message]):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
assert all(isinstance(block, dict) for block in full.content)
approval_message = HumanMessage(
[
{
"type": "non_standard",
"value": {
"type": "mcp_approval_response",
"approve": True,
"approval_request_id": block["value"]["id"], # type: ignore[index]
},
}
for block in full.content_blocks
if block["type"] == "non_standard"
and block["value"]["type"] == "mcp_approval_request" # type: ignore[index]
]
)
_ = llm_with_tools.invoke([input_message, full, approval_message])
@pytest.mark.default_cassette("test_image_generation_streaming.yaml.gz")
@pytest.mark.vcr
@pytest.mark.parametrize("output_version", ["v0", "responses/v1"])
def test_image_generation_streaming(
output_version: Literal["v0", "responses/v1"],
) -> None:
"""Test image generation streaming."""
llm = ChatOpenAI(model="gpt-4.1", use_responses_api=True)
llm = ChatOpenAI(
model="gpt-4.1", use_responses_api=True, output_version=output_version
)
tool = {
"type": "image_generation",
# For testing purposes let's keep the quality low, so the test runs faster.
@@ -598,15 +742,77 @@ def test_image_generation_streaming() -> None:
# At the moment, the streaming API does not pick up annotations fully.
# So the following check is commented out.
# _check_response(complete_ai_message)
tool_output = complete_ai_message.additional_kwargs["tool_outputs"][0]
assert set(tool_output.keys()).issubset(expected_keys)
if output_version == "v0":
assert complete_ai_message.additional_kwargs["tool_outputs"]
tool_output = complete_ai_message.additional_kwargs["tool_outputs"][0]
assert set(tool_output.keys()).issubset(expected_keys)
elif output_version == "responses/v1":
tool_output = next(
block
for block in complete_ai_message.content
if isinstance(block, dict) and block["type"] == "image_generation_call"
)
assert set(tool_output.keys()).issubset(expected_keys)
else:
# v1
standard_keys = {"type", "base64", "id", "status", "index"}
tool_output = next(
block
for block in complete_ai_message.content
if isinstance(block, dict) and block["type"] == "image"
)
assert set(standard_keys).issubset(tool_output.keys())
@pytest.mark.vcr()
def test_image_generation_multi_turn() -> None:
@pytest.mark.default_cassette("test_image_generation_streaming.yaml.gz")
@pytest.mark.vcr
def test_image_generation_streaming_v1() -> None:
"""Test image generation streaming."""
llm = ChatOpenAI(model="gpt-4.1", use_responses_api=True, output_version="v1")
tool = {
"type": "image_generation",
"quality": "low",
"output_format": "jpeg",
"output_compression": 100,
"size": "1024x1024",
}
standard_keys = {"type", "base64", "mime_type", "id", "index"}
extra_keys = {
"background",
"output_format",
"quality",
"revised_prompt",
"size",
"status",
}
full: Optional[BaseMessageChunk] = None
for chunk in llm.stream("Draw a random short word in green font.", tools=[tool]):
assert isinstance(chunk, AIMessageChunk)
full = chunk if full is None else full + chunk
complete_ai_message = cast(AIMessageChunk, full)
tool_output = next(
block
for block in complete_ai_message.content
if isinstance(block, dict) and block["type"] == "image"
)
assert set(standard_keys).issubset(tool_output.keys())
assert set(extra_keys).issubset(tool_output["extras"].keys())
@pytest.mark.default_cassette("test_image_generation_multi_turn.yaml.gz")
@pytest.mark.vcr
@pytest.mark.parametrize("output_version", ["v0", "responses/v1"])
def test_image_generation_multi_turn(
output_version: Literal["v0", "responses/v1"],
) -> None:
"""Test multi-turn editing of image generation by passing in history."""
# Test multi-turn
llm = ChatOpenAI(model="gpt-4.1", use_responses_api=True)
llm = ChatOpenAI(
model="gpt-4.1", use_responses_api=True, output_version=output_version
)
# Test invocation
tool = {
"type": "image_generation",
@@ -622,10 +828,41 @@ def test_image_generation_multi_turn() -> None:
{"role": "user", "content": "Draw a random short word in green font."}
]
ai_message = llm_with_tools.invoke(chat_history)
_check_response(ai_message)
tool_output = ai_message.additional_kwargs["tool_outputs"][0]
assert isinstance(ai_message, AIMessage)
_check_response(ai_message, output_version)
# Example tool output for an image
expected_keys = {
"id",
"background",
"output_format",
"quality",
"result",
"revised_prompt",
"size",
"status",
"type",
}
if output_version == "v0":
tool_output = ai_message.additional_kwargs["tool_outputs"][0]
assert set(tool_output.keys()).issubset(expected_keys)
elif output_version == "responses/v1":
tool_output = next(
block
for block in ai_message.content
if isinstance(block, dict) and block["type"] == "image_generation_call"
)
assert set(tool_output.keys()).issubset(expected_keys)
else:
standard_keys = {"type", "base64", "id", "status"}
tool_output = next(
block
for block in ai_message.content
if isinstance(block, dict) and block["type"] == "image"
)
assert set(standard_keys).issubset(tool_output.keys())
# Example tool output for an image (v0)
# {
# "background": "opaque",
# "id": "ig_683716a8ddf0819888572b20621c7ae4029ec8c11f8dacf8",
@@ -641,20 +878,6 @@ def test_image_generation_multi_turn() -> None:
# "result": # base64 encode image data
# }
expected_keys = {
"id",
"background",
"output_format",
"quality",
"result",
"revised_prompt",
"size",
"status",
"type",
}
assert set(tool_output.keys()).issubset(expected_keys)
chat_history.extend(
[
# AI message with tool output
@@ -671,9 +894,96 @@ def test_image_generation_multi_turn() -> None:
)
ai_message2 = llm_with_tools.invoke(chat_history)
_check_response(ai_message2)
tool_output2 = ai_message2.additional_kwargs["tool_outputs"][0]
assert set(tool_output2.keys()).issubset(expected_keys)
assert isinstance(ai_message2, AIMessage)
_check_response(ai_message2, output_version)
if output_version == "v0":
tool_output = ai_message2.additional_kwargs["tool_outputs"][0]
assert set(tool_output.keys()).issubset(expected_keys)
elif output_version == "responses/v1":
tool_output = next(
block
for block in ai_message2.content
if isinstance(block, dict) and block["type"] == "image_generation_call"
)
assert set(tool_output.keys()).issubset(expected_keys)
else:
standard_keys = {"type", "base64", "id", "status"}
tool_output = next(
block
for block in ai_message2.content
if isinstance(block, dict) and block["type"] == "image"
)
assert set(standard_keys).issubset(tool_output.keys())
@pytest.mark.default_cassette("test_image_generation_multi_turn.yaml.gz")
@pytest.mark.vcr
def test_image_generation_multi_turn_v1() -> None:
"""Test multi-turn editing of image generation by passing in history."""
# Test multi-turn
llm = ChatOpenAI(model="gpt-4.1", use_responses_api=True, output_version="v1")
# Test invocation
tool = {
"type": "image_generation",
"quality": "low",
"output_format": "jpeg",
"output_compression": 100,
"size": "1024x1024",
}
llm_with_tools = llm.bind_tools([tool])
chat_history: list[MessageLikeRepresentation] = [
{"role": "user", "content": "Draw a random short word in green font."}
]
ai_message = llm_with_tools.invoke(chat_history)
assert isinstance(ai_message, AIMessage)
_check_response(ai_message, "v1")
standard_keys = {"type", "base64", "mime_type", "id"}
extra_keys = {
"background",
"output_format",
"quality",
"revised_prompt",
"size",
"status",
}
tool_output = next(
block
for block in ai_message.content
if isinstance(block, dict) and block["type"] == "image"
)
assert set(standard_keys).issubset(tool_output.keys())
assert set(extra_keys).issubset(tool_output["extras"].keys())
chat_history.extend(
[
# AI message with tool output
ai_message,
# New request
{
"role": "user",
"content": (
"Now, change the font to blue. Keep the word and everything else "
"the same."
),
},
]
)
ai_message2 = llm_with_tools.invoke(chat_history)
assert isinstance(ai_message2, AIMessage)
_check_response(ai_message2, "v1")
tool_output = next(
block
for block in ai_message2.content
if isinstance(block, dict) and block["type"] == "image"
)
assert set(standard_keys).issubset(tool_output.keys())
assert set(extra_keys).issubset(tool_output["extras"].keys())
def test_verbosity_parameter() -> None:
@@ -689,14 +999,16 @@ def test_verbosity_parameter() -> None:
assert response.content
@pytest.mark.vcr()
def test_custom_tool() -> None:
@pytest.mark.default_cassette("test_custom_tool.yaml.gz")
@pytest.mark.vcr
@pytest.mark.parametrize("output_version", ["responses/v1", "v1"])
def test_custom_tool(output_version: Literal["responses/v1", "v1"]) -> None:
@custom_tool
def execute_code(code: str) -> str:
"""Execute python code."""
return "27"
llm = ChatOpenAI(model="gpt-5", output_version="responses/v1").bind_tools(
llm = ChatOpenAI(model="gpt-5", output_version=output_version).bind_tools(
[execute_code]
)

View File

@@ -20,6 +20,7 @@ from langchain_core.messages import (
ToolCall,
ToolMessage,
)
from langchain_core.messages import content_blocks as types
from langchain_core.messages.ai import UsageMetadata
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.runnables import RunnableLambda
@@ -51,7 +52,10 @@ 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_responses,
)
from langchain_openai.chat_models.base import (
_construct_lc_result_from_responses_api,
@@ -2374,7 +2378,7 @@ def test_mcp_tracing() -> None:
assert payload["tools"][0]["headers"]["Authorization"] == "Bearer PLACEHOLDER"
def test_compat() -> None:
def test_compat_responses_v03() -> None:
# Check compatibility with v0.3 message format
message_v03 = AIMessage(
content=[
@@ -2435,6 +2439,260 @@ 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",
"name": "get_weather",
"args": {"location": "San Francisco"},
},
{
"type": "text",
"text": "Hello, world!",
"annotations": [
{"type": "citation", "url": "https://example.com"}
],
},
],
id="chatcmpl-123",
response_metadata={"model_provider": "openai", "model_name": "gpt-4.1"},
),
AIMessage(
[{"type": "text", "text": "Hello, world!"}],
id="chatcmpl-123",
response_metadata={"model_provider": "openai", "model_name": "gpt-4.1"},
),
)
],
)
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
assert result.tool_calls == message_v1.tool_calls # tool calls remain cached
# Check no mutation
assert message_v1 != result
@pytest.mark.parametrize(
"message_v1, expected",
[
(
AIMessage(
content_blocks=[
{"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",
"args": {"location": "San Francisco"},
},
{
"type": "tool_call",
"id": "call_234",
"name": "get_weather_2",
"args": {"location": "New York"},
"extras": {"item_id": "fc_123"},
},
{"type": "text", "text": "Hello "},
{
"type": "text",
"text": "world",
"annotations": [
{"type": "citation", "url": "https://example.com"},
{
"type": "citation",
"title": "my doc",
"extras": {"file_id": "file_123", "index": 1},
},
{
"type": "non_standard_annotation",
"value": {"bar": "baz"},
},
],
},
{"type": "image", "base64": "...", "id": "ig_123"},
{
"type": "non_standard",
"value": {"type": "something_else", "foo": "bar"},
},
],
id="resp123",
),
[
{"type": "reasoning", "id": "abc123", "summary": []},
{
"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": "ig_123", "result": "..."},
{"type": "something_else", "foo": "bar"},
],
)
],
)
def test_convert_from_v1_to_responses(
message_v1: AIMessage, expected: list[dict[str, Any]]
) -> None:
result = _convert_from_v1_to_responses(
message_v1.content_blocks, message_v1.tool_calls
)
assert result == expected
# Check no mutation
assert message_v1 != result
@pytest.mark.parametrize(
"responses_content, tool_calls, expected_content",
[
(
[
{"type": "reasoning", "id": "abc123", "summary": []},
{
"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": "ig_123", "result": "..."},
{"type": "something_else", "foo": "bar"},
],
[
{
"type": "tool_call",
"id": "call_123",
"name": "get_weather",
"args": {"location": "San Francisco"},
},
{
"type": "tool_call",
"id": "call_234",
"name": "get_weather_2",
"args": {"location": "New York"},
},
],
[
{"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",
"args": {"location": "San Francisco"},
},
{
"type": "tool_call",
"id": "call_234",
"name": "get_weather_2",
"args": {"location": "New York"},
"extras": {"item_id": "fc_123"},
},
{"type": "text", "text": "Hello "},
{
"type": "text",
"text": "world",
"annotations": [
{"type": "citation", "url": "https://example.com"},
{
"type": "citation",
"title": "my doc",
"extras": {"file_id": "file_123", "index": 1},
},
{"type": "non_standard_annotation", "value": {"bar": "baz"}},
],
},
{"type": "image", "base64": "...", "id": "ig_123"},
{
"type": "non_standard",
"value": {"type": "something_else", "foo": "bar"},
},
],
)
],
)
def test_convert_to_v1_from_responses(
responses_content: list[dict[str, Any]],
tool_calls: list[ToolCall],
expected_content: list[types.ContentBlock],
) -> None:
result = _convert_to_v1_from_responses(responses_content, tool_calls)
assert result == expected_content
def test_get_last_messages() -> None:
messages: list[BaseMessage] = [HumanMessage("Hello")]
last_messages, previous_response_id = _get_last_messages(messages)

View File

@@ -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,
@@ -337,7 +338,7 @@ responses_stream = [
id="rs_234",
summary=[],
type="reasoning",
encrypted_content=None,
encrypted_content="encrypted-content",
status=None,
),
output_index=2,
@@ -416,7 +417,7 @@ responses_stream = [
Summary(text="still more reasoning", type="summary_text"),
],
type="reasoning",
encrypted_content=None,
encrypted_content="encrypted-content",
status=None,
),
output_index=2,
@@ -562,7 +563,7 @@ responses_stream = [
Summary(text="still more reasoning", type="summary_text"),
],
type="reasoning",
encrypted_content=None,
encrypted_content="encrypted-content",
status=None,
),
ResponseOutputMessage(
@@ -620,8 +621,99 @@ 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",
},
],
"encrypted_content": "encrypted-content",
"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",
"extras": {"encrypted_content": "encrypted-content"},
"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:
@@ -630,36 +722,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
chunks.append(chunk)
assert isinstance(full, AIMessageChunk)
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 full.content == expected_content
assert full.additional_kwargs == {}
assert full.id == "resp_123"