diff --git a/libs/core/langchain_core/messages/ai.py b/libs/core/langchain_core/messages/ai.py index 92bac634d6914..750b80382e8f1 100644 --- a/libs/core/langchain_core/messages/ai.py +++ b/libs/core/langchain_core/messages/ai.py @@ -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") @@ -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") diff --git a/libs/core/tests/unit_tests/messages/test_ai.py b/libs/core/tests/unit_tests/messages/test_ai.py index 31f8b3e1bb539..d279cc4cef9fe 100644 --- a/libs/core/tests/unit_tests/messages/test_ai.py +++ b/libs/core/tests/unit_tests/messages/test_ai.py @@ -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( diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 629b34ee36dfd..e4f42f7428c1f 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -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 @@ -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 ) diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py index 291fee686b171..5464b00e62417 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py @@ -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)