Compare commits

...

17 Commits

Author SHA1 Message Date
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
24 changed files with 3513 additions and 417 deletions

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,20 @@ 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 AIMessages
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

@@ -33,9 +33,31 @@ if TYPE_CHECKING:
)
from langchain_core.messages.chat import ChatMessage, ChatMessageChunk
from langchain_core.messages.content_blocks import (
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,
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
@@ -65,24 +87,42 @@ if TYPE_CHECKING:
__all__ = (
"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",
@@ -91,6 +131,10 @@ __all__ = (
"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",
@@ -103,25 +147,43 @@ __all__ = (
_dynamic_imports = {
"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 +194,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,26 @@ 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_AUTO_PREFIX = "lc_"
"""LangChain auto-generated ID prefix for messages and content blocks."""
_LC_ID_PREFIX = "run-"
_LC_ID_PREFIX = f"{_LC_AUTO_PREFIX}run-"
"""Internal tracing/callback system identifier.
Used for:
- Tracing. Every LangChain operation (LLM call, chain execution, tool use, etc.)
gets a unique run_id (UUID)
- Enables tracking parent-child relationships between operations
"""
class InputTokenDetails(TypedDict, total=False):
@@ -180,16 +180,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 a positional arg or content_blocks for typing support."""
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 +225,33 @@ 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
# 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 +280,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"):
@@ -428,17 +483,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(_LC_ID_PREFIX)
and not id_.startswith(_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(_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,12 @@
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.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 +62,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 a positional arg or content_blocks for typing support."""
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 +106,44 @@ class BaseMessage(Serializable):
"""
return ["langchain", "schema", "messages"]
@property
def content_blocks(self) -> list[types.ContentBlock]:
"""Return the content as a list of standard ContentBlocks.
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 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 a positional arg or content_blocks for typing support."""
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 a positional arg or content_blocks for typing support."""
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 a positional arg or content_blocks for typing support."""
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

@@ -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

@@ -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,90 @@ 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,
]

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

@@ -1221,15 +1221,30 @@ def test_convert_to_openai_messages_multimodal() -> None:
{"type": "text", "text": "Text message"},
{
"type": "image",
"source_type": "url",
"url": "https://example.com/test.png",
},
{
"type": "image",
"source_type": "url", # backward compatibility
"url": "https://example.com/test.png",
},
{
"type": "image",
"base64": "<base64 string>",
"mime_type": "image/png",
},
{
"type": "image",
"source_type": "base64",
"data": "<base64 string>",
"mime_type": "image/png",
},
{
"type": "file",
"base64": "<base64 string>",
"mime_type": "application/pdf",
"filename": "test.pdf",
},
{
"type": "file",
"source_type": "base64",
@@ -1244,11 +1259,20 @@ def test_convert_to_openai_messages_multimodal() -> None:
"file_data": "data:application/pdf;base64,<base64 string>",
},
},
{
"type": "file",
"file_id": "file-abc123",
},
{
"type": "file",
"source_type": "id",
"id": "file-abc123",
},
{
"type": "audio",
"base64": "<base64 string>",
"mime_type": "audio/wav",
},
{
"type": "audio",
"source_type": "base64",
@@ -1268,7 +1292,7 @@ def test_convert_to_openai_messages_multimodal() -> None:
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
messages = [
@@ -1276,8 +1300,7 @@ def test_convert_to_openai_messages_multimodal() -> None:
content=[
{
"type": "file",
"source_type": "base64",
"data": "<base64 string>",
"base64": "<base64 string>",
"mime_type": "application/pdf",
},
]

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

@@ -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
@@ -181,16 +183,17 @@ def test_message_chunks() -> None:
# Test ID order of precedence
null_id = AIMessageChunk(content="", id=None)
default_id = AIMessageChunk(
content="", id="run-abc123"
content="", id="lc_run--abc123"
) # LangChain-assigned run ID
meaningful_id = AIMessageChunk(content="", id="msg_def456") # provider-assigned ID
assert (null_id + default_id).id == "run-abc123"
assert (default_id + null_id).id == "run-abc123"
assert (null_id + default_id).id == "lc_run--abc123"
assert (default_id + null_id).id == "lc_run--abc123"
assert (null_id + meaningful_id).id == "msg_def456"
assert (meaningful_id + null_id).id == "msg_def456"
# Provider assigned IDs have highest precedence
assert (default_id + meaningful_id).id == "msg_def456"
assert (meaningful_id + default_id).id == "msg_def456"
@@ -207,7 +210,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 +319,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 +335,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 +1041,12 @@ 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]
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]
)
@@ -1116,23 +1119,20 @@ def test_is_data_content_block() -> None:
assert is_data_content_block(
{
"type": "image",
"source_type": "url",
"url": "https://...",
}
)
assert is_data_content_block(
{
"type": "image",
"source_type": "base64",
"data": "<base64 data>",
"base64": "<base64 data>",
"mime_type": "image/jpeg",
}
)
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,13 +1140,17 @@ 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 is_data_content_block(
{
"type": "image",
"source_type": "base64", # backward compatibility
}
)
assert not is_data_content_block(
{
"type": "text",
@@ -1159,12 +1163,6 @@ def test_is_data_content_block() -> None:
"image_url": {"url": "https://..."},
}
)
assert not is_data_content_block(
{
"type": "image",
"source_type": "base64",
}
)
assert not is_data_content_block(
{
"type": "image",
@@ -1174,31 +1172,105 @@ def test_is_data_content_block() -> None:
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,483 @@ 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:
if id_ := tool_call.get("id"):
message.content.append({"type": "tool_call", "id": id_})
if "tool_calls" in message.additional_kwargs:
_ = message.additional_kwargs.pop("tool_calls")
if "token_usage" in message.response_metadata:
_ = message.response_metadata.pop("token_usage")
return message
def _convert_to_v1_from_chat_completions_chunk(chunk: AIMessageChunk) -> AIMessageChunk:
result = _convert_to_v1_from_chat_completions(cast(AIMessage, chunk))
return cast(AIMessageChunk, result)
def _convert_from_v1_to_chat_completions(message: AIMessage) -> AIMessage:
"""Convert a v1 message to the Chat Completions format."""
if isinstance(message.content, list):
new_content: list = []
for block in message.content:
if isinstance(block, dict):
block_type = block.get("type")
if block_type == "text":
# Strip annotations
new_content.append({"type": "text", "text": block["text"]})
elif block_type in ("reasoning", "tool_call"):
pass
else:
new_content.append(block)
else:
new_content.append(block)
return message.model_copy(update={"content": new_content})
return message
# v1 / Responses
def _convert_annotation_to_v1(annotation: dict[str, Any]) -> 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(
@@ -1282,6 +1333,12 @@ class BaseChatOpenAI(BaseChatModel):
if hasattr(message, "refusal"):
generations[0].message.additional_kwargs["refusal"] = message.refusal
if self.output_version == "v1":
_ = llm_output.pop("token_usage", None)
generations[0].message = _convert_to_v1_from_chat_completions(
cast(AIMessage, generations[0].message)
)
return ChatResult(generations=generations, llm_output=llm_output)
async def _astream(
@@ -3384,6 +3441,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:
@@ -3666,7 +3737,20 @@ 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")
@@ -3828,7 +3912,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:
@@ -3966,6 +4050,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 +4098,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 +4164,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 +4279,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 +4329,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:
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:

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"