diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index e2d6867e69d..4b93946a6f5 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -3611,6 +3611,55 @@ def _construct_responses_api_payload( return payload +def _convert_chat_completions_blocks_to_responses( + block: dict[str, Any], +) -> dict[str, Any]: + """Convert chat completions content blocks to responses API format. + + Only handles text, image, file blocks. Others pass through. + """ + if block["type"] == "text": + # chat api: {"type": "text", "text": "..."} + # responses api: {"type": "input_text", "text": "..."} + return {"type": "input_text", "text": block["text"]} + if block["type"] == "image_url": + # chat api: {"type": "image_url", "image_url": {"url": "...", "detail": "..."}} # noqa: E501 + # responses api: {"type": "image_url", "image_url": "...", "detail": "...", "file_id": "..."} # noqa: E501 + new_block = { + "type": "input_image", + "image_url": block["image_url"]["url"], + } + if block["image_url"].get("detail"): + new_block["detail"] = block["image_url"]["detail"] + return new_block + if block["type"] == "file": + return {"type": "input_file", **block["file"]} + return block + + +def _ensure_valid_tool_message_content(tool_output: Any) -> Union[str, list[dict]]: + if isinstance(tool_output, str): + return tool_output + if isinstance(tool_output, list) and all( + isinstance(block, dict) + and block.get("type") + in ( + "input_text", + "input_image", + "input_file", + "text", + "image_url", + "file", + ) + for block in tool_output + ): + return [ + _convert_chat_completions_blocks_to_responses(block) + for block in tool_output + ] + return _stringify(tool_output) + + def _make_computer_call_output_from_message(message: ToolMessage) -> dict: computer_call_output: dict = { "call_id": message.tool_call_id, @@ -3684,8 +3733,7 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list: ) input_.append(computer_call_output) else: - if not isinstance(tool_output, str): - tool_output = _stringify(tool_output) + tool_output = _ensure_valid_tool_message_content(tool_output) function_call_output = { "type": "function_call_output", "output": tool_output, @@ -3785,23 +3833,10 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list: new_blocks = [] non_message_item_types = ("mcp_approval_response",) for block in msg["content"]: - # chat api: {"type": "text", "text": "..."} - # responses api: {"type": "input_text", "text": "..."} - if block["type"] == "text": - new_blocks.append({"type": "input_text", "text": block["text"]}) - # chat api: {"type": "image_url", "image_url": {"url": "...", "detail": "..."}} # noqa: E501 - # responses api: {"type": "image_url", "image_url": "...", "detail": "...", "file_id": "..."} # noqa: E501 - elif block["type"] == "image_url": - new_block = { - "type": "input_image", - "image_url": block["image_url"]["url"], - } - if block["image_url"].get("detail"): - new_block["detail"] = block["image_url"]["detail"] - new_blocks.append(new_block) - elif block["type"] == "file": - new_block = {"type": "input_file", **block["file"]} - new_blocks.append(new_block) + if block["type"] in ("text", "image_url", "file"): + new_blocks.append( + _convert_chat_completions_blocks_to_responses(block) + ) elif block["type"] in ("input_text", "input_image", "input_file"): new_blocks.append(block) elif block["type"] in non_message_item_types: diff --git a/libs/partners/openai/pyproject.toml b/libs/partners/openai/pyproject.toml index d50ceae84e4..d32f4999550 100644 --- a/libs/partners/openai/pyproject.toml +++ b/libs/partners/openai/pyproject.toml @@ -8,7 +8,7 @@ license = { text = "MIT" } requires-python = ">=3.9.0,<4.0.0" dependencies = [ "langchain-core>=0.3.76,<2.0.0", - "openai>=1.104.2,<2.0.0", + "openai>=1.104.2,<3.0.0", "tiktoken>=0.7.0,<1.0.0", ] name = "langchain-openai" diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_responses_standard.py b/libs/partners/openai/tests/integration_tests/chat_models/test_responses_standard.py index e4f1d182941..26e2b3e588f 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_responses_standard.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_responses_standard.py @@ -22,6 +22,10 @@ class TestOpenAIResponses(TestOpenAIStandard): def chat_model_params(self) -> dict: return {"model": "gpt-4o-mini", "use_responses_api": True} + @property + def supports_image_tool_message(self) -> bool: + return True + @pytest.mark.xfail(reason="Unsupported.") def test_stop_sequence(self, model: BaseChatModel) -> None: super().test_stop_sequence(model) diff --git a/libs/partners/openai/uv.lock b/libs/partners/openai/uv.lock index e11a93a20e5..08ccf347c76 100644 --- a/libs/partners/openai/uv.lock +++ b/libs/partners/openai/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.9.0, <4.0.0" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", @@ -647,7 +647,7 @@ typing = [ [package.metadata] requires-dist = [ { name = "langchain-core", editable = "../../core" }, - { name = "openai", specifier = ">=1.104.2,<2.0.0" }, + { name = "openai", specifier = ">=1.104.2,<3.0.0" }, { name = "tiktoken", specifier = ">=0.7.0,<1.0.0" }, ] @@ -1183,7 +1183,7 @@ wheels = [ [[package]] name = "openai" -version = "1.107.3" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1195,9 +1195,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/24/7fb5749bcf66b52209e3ece05cb4eaeae2102e95f8ae77589e8afaf70ba8/openai-1.107.3.tar.gz", hash = "sha256:69bb8032b05c5f00f7660e422f70f9aabc94793b9a30c5f899360ed21e46314f", size = 564194, upload-time = "2025-09-15T20:09:20.159Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/5d/74fa2b0358ef15d113b1a6ca2323cee0034020b085a81a94eeddc6914de9/openai-2.0.0.tar.gz", hash = "sha256:6b9513b485f856b0be6bc44c518831acb58e37a12bed72fcc52b1177d1fb34a8", size = 565732, upload-time = "2025-09-30T17:35:57.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/1d/58ad0084451f64a9193de48c0afd63047682ffdedb6ae1d494a203e03fd5/openai-1.107.3-py3-none-any.whl", hash = "sha256:4ca54a847235ac04c6320da70fdc06b62d71439de9ec0aa40d5690c3064d4025", size = 947600, upload-time = "2025-09-15T20:09:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/69/41/86ddc9cdd885acc02ee50ec24ea1c5e324eea0c7a471ee841a7088653558/openai-2.0.0-py3-none-any.whl", hash = "sha256:a79f493651f9843a6c54789a83f3b2db56df0e1770f7dcbe98bcf0e967ee2148", size = 955538, upload-time = "2025-09-30T17:35:54.695Z" }, ] [[package]]