Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions libs/core/langchain_core/messages/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,11 @@ def content_blocks(self) -> list[types.ContentBlock]:
first before falling back to best-effort parsing. For details, see the property
on `BaseMessage`.
"""
if self.response_metadata.get("output_version") == "v1":
if self.response_metadata.get("output_version") == "v1" and isinstance(
self.content, list
):
# Only short-circuit when content is already a list of ContentBlock
# dicts. See AIMessageChunk.content_blocks for full rationale.
return cast("list[types.ContentBlock]", self.content)

model_provider = self.response_metadata.get("model_provider")
Expand Down Expand Up @@ -440,7 +444,16 @@ def lc_attributes(self) -> dict:
@property
def content_blocks(self) -> list[types.ContentBlock]:
"""Return standard, typed `ContentBlock` dicts from the message."""
if self.response_metadata.get("output_version") == "v1":
if self.response_metadata.get("output_version") == "v1" and isinstance(
self.content, list
):
# Only short-circuit when content is already a list of ContentBlock
# dicts. Some streaming implementations keep content as a string
# even when output_version="v1" is set (e.g., OpenAI Chat
# Completions), so it must fall through to the model_provider
# translator which builds ContentBlock dicts from tool_calls /
# tool_call_chunks. Without this guard, string content would be
# returned directly, silently dropping tool calls.
return cast("list[types.ContentBlock]", self.content)

model_provider = self.response_metadata.get("model_provider")
Expand Down
43 changes: 43 additions & 0 deletions libs/core/tests/unit_tests/messages/test_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,49 @@ def test_content_blocks() -> None:
]


def test_content_blocks_v1_string_content_falls_through() -> None:
"""Test that content_blocks falls through to translator when content is a string.

When output_version="v1" is set but content is a string (as in Chat
Completions streaming), content_blocks must not short-circuit. It should
fall through to the model_provider translator so tool calls are included.
"""
# AIMessage with string content + tool_calls + v1 metadata
msg = AIMessage(
content="Hello",
tool_calls=[
create_tool_call(name="foo", args={"a": 1}, id="tc_1"),
],
response_metadata={
"output_version": "v1",
"model_provider": "openai",
},
)
blocks = msg.content_blocks
assert isinstance(blocks, list)
# Should contain a text block and a tool_call block, not the raw string
types_found = {b["type"] for b in blocks}
assert "text" in types_found
assert "tool_call" in types_found

# AIMessageChunk with string content + tool_call_chunks + v1 metadata
chunk = AIMessageChunk(
content="Hello",
tool_call_chunks=[
create_tool_call_chunk(name="foo", args='{"a": 1}', id="tc_1", index=0),
],
response_metadata={
"output_version": "v1",
"model_provider": "openai",
},
)
blocks = chunk.content_blocks
assert isinstance(blocks, list)
types_found = {b["type"] for b in blocks}
assert "text" in types_found
assert "tool_call_chunk" in types_found


def test_content_blocks_reasoning_extraction() -> None:
"""Test best-effort reasoning extraction from `additional_kwargs`."""
message = AIMessage(
Expand Down
10 changes: 9 additions & 1 deletion libs/partners/openai/langchain_openai/chat_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1185,8 +1185,13 @@ def _convert_chunk_to_generation_chunk(
message=default_chunk_class(content="", usage_metadata=usage_metadata),
generation_info=base_generation_info,
)
# Keep content as "" (the default) rather than converting to [].
# All Chat Completions content chunks arrive as strings. Starting
# with [] causes merge_content to silently drop string content
# (empty list is falsy, so no merge branch applies). The empty
# list also triggers the content_blocks isinstance(list) short-
# circuit, which would return [] and miss tool_call_chunks.
if self.output_version == "v1":
generation_chunk.message.content = []
generation_chunk.message.response_metadata["output_version"] = "v1"

return generation_chunk
Expand Down Expand Up @@ -1217,6 +1222,9 @@ def _convert_chunk_to_generation_chunk(
message_chunk.usage_metadata = usage_metadata

message_chunk.response_metadata["model_provider"] = "openai"
# Propagate output_version so content_blocks can detect v1 mode.
if self.output_version == "v1":
message_chunk.response_metadata["output_version"] = "v1"
return ChatGenerationChunk(
message=message_chunk, generation_info=generation_info or None
)
Expand Down
172 changes: 172 additions & 0 deletions libs/partners/openai/tests/unit_tests/chat_models/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,178 @@ def test_output_version_compat() -> None:
assert llm._use_responses_api({}) is True


def test_convert_chunk_to_generation_chunk_v1_keeps_string_content() -> None:
"""Verify _convert_chunk_to_generation_chunk with output_version='v1'."""
llm = ChatOpenAI(model="gpt-4o", output_version="v1")

# Empty-choices chunk (usage-only)
empty_chunk: dict[str, Any] = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
gen = llm._convert_chunk_to_generation_chunk(empty_chunk, AIMessageChunk, None)
assert gen is not None
assert gen.message.content == "" # NOT []
assert gen.message.response_metadata.get("output_version") == "v1"

# Content-bearing chunk with tool_call delta
tool_chunk: dict[str, Any] = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"index": 0,
"id": "call_abc",
"function": {"name": "get_weather", "arguments": ""},
}
],
},
"logprobs": None,
"finish_reason": None,
}
],
"usage": None,
}
gen = llm._convert_chunk_to_generation_chunk(tool_chunk, AIMessageChunk, None)
assert gen is not None
assert isinstance(gen.message.content, str)
assert gen.message.response_metadata.get("output_version") == "v1"
assert gen.message.response_metadata.get("model_provider") == "openai"


def test_v1_streaming_tool_calls_in_content_blocks() -> None:
"""End-to-end: streaming chunks with tool calls produce correct content_blocks."""
stream_chunks: list[dict[str, Any]] = [
# Initial empty-choices chunk
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": ""},
"logprobs": None,
"finish_reason": None,
}
],
"usage": None,
},
# Tool call start
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_abc",
"function": {
"name": "get_weather",
"arguments": '{"loc',
},
}
]
},
"logprobs": None,
"finish_reason": None,
}
],
"usage": None,
},
# Tool call args continuation
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{
"index": 0,
"function": {"arguments": 'ation": "SF"}'},
}
]
},
"logprobs": None,
"finish_reason": None,
}
],
"usage": None,
},
# Finish
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"delta": {},
"logprobs": None,
"finish_reason": "tool_calls",
}
],
"usage": None,
},
# Usage chunk
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-4o",
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
},
]

llm = ChatOpenAI(model="gpt-4o", output_version="v1")

aggregated: AIMessageChunk | None = None
for raw_chunk in stream_chunks:
gen = llm._convert_chunk_to_generation_chunk(raw_chunk, AIMessageChunk, None)
if gen is None:
continue
chunk = cast(AIMessageChunk, gen.message)
aggregated = chunk if aggregated is None else aggregated + chunk

assert aggregated is not None
# Tool calls should be present
assert len(aggregated.tool_call_chunks) == 1
assert aggregated.tool_call_chunks[0]["name"] == "get_weather"

# content_blocks should include tool_call_chunk blocks
blocks = aggregated.content_blocks
block_types = {b["type"] for b in blocks}
assert "tool_call_chunk" in block_types


def test_verbosity_parameter_payload() -> None:
"""Test verbosity parameter is included in request payload for Responses API."""
llm = ChatOpenAI(model="gpt-5", verbosity="high", use_responses_api=True)
Expand Down
Loading