diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 5d15fec5db7..c5bd5b63d82 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -4589,6 +4589,15 @@ def _construct_lc_result_from_responses_api( return ChatResult(generations=[ChatGeneration(message=message)]) +def _coerce_chunk_response(resp: Any) -> Any: + # dict `response` items on stream events have been observed in the wild + if isinstance(resp, dict): + from openai.types.responses import Response + + return Response.model_validate(resp) + return resp + + def _convert_responses_chunk_to_generation_chunk( chunk: Any, current_index: int, # index in content @@ -4686,14 +4695,16 @@ def _convert_responses_chunk_to_generation_chunk( } ) elif chunk.type == "response.created": - id = chunk.response.id - response_metadata["id"] = chunk.response.id # Backwards compatibility + response = _coerce_chunk_response(chunk.response) + id = response.id + response_metadata["id"] = response.id # Backwards compatibility elif chunk.type in ("response.completed", "response.incomplete"): + response = _coerce_chunk_response(chunk.response) msg = cast( AIMessage, ( _construct_lc_result_from_responses_api( - chunk.response, schema=schema, output_version=output_version + response, schema=schema, output_version=output_version ) .generations[0] .message diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_responses_stream.py b/libs/partners/openai/tests/unit_tests/chat_models/test_responses_stream.py index fc21d90143f..6e9a3f52575 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_responses_stream.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_responses_stream.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from typing import Any from unittest.mock import MagicMock, patch @@ -986,3 +987,32 @@ def test_responses_stream_function_call_preserves_namespace() -> None: assert first_block.get("namespace") == "my_namespace", ( f"Expected namespace 'my_namespace', got {first_block.get('namespace')}" ) + + +def test_responses_stream_tolerates_dict_response_field() -> None: + """Regression test for `AttributeError: 'dict' object has no attribute 'id'`. + + The OpenAI SDK types `.response` strictly as `Response`, but raw dicts + have been observed in the wild. + """ + stream = copy.deepcopy(responses_stream) + first_event = stream[0] + assert isinstance(first_event, ResponseCreatedEvent) + first_event.response = first_event.response.model_dump(mode="json") # type: ignore[assignment] + assert isinstance(first_event.response, dict) + + llm = ChatOpenAI(model="o4-mini", use_responses_api=True) + mock_client = MagicMock() + + def mock_create(*args: Any, **kwargs: Any) -> MockSyncContextManager: + return MockSyncContextManager(stream) + + mock_client.responses.create = mock_create + + full: BaseMessageChunk | None = None + 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 + assert isinstance(full, AIMessageChunk) + assert full.id == "resp_123" diff --git a/libs/partners/openai/uv.lock b/libs/partners/openai/uv.lock index 29c562994fd..491724144b2 100644 --- a/libs/partners/openai/uv.lock +++ b/libs/partners/openai/uv.lock @@ -624,7 +624,7 @@ typing = [ [[package]] name = "langchain-core" -version = "1.3.0a2" +version = "1.3.0" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, @@ -1120,7 +1120,7 @@ wheels = [ [[package]] name = "openai" -version = "2.29.0" +version = "2.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1132,9 +1132,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" }, ] [[package]]