diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index fefa81ea420..afdcf47445b 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -1142,42 +1142,51 @@ class BaseChatOpenAI(BaseChatModel): return generate_from_stream(stream_iter) payload = self._get_request_payload(messages, stop=stop, **kwargs) generation_info = None - if "response_format" in payload: - if self.include_response_headers: - warnings.warn( - "Cannot currently include response headers when response_format is " - "specified." - ) - payload.pop("stream") - try: - response = self.root_client.beta.chat.completions.parse(**payload) - except openai.BadRequestError as e: - _handle_openai_bad_request(e) - elif self._use_responses_api(payload): - original_schema_obj = kwargs.get("response_format") - if original_schema_obj and _is_pydantic_class(original_schema_obj): - response = self.root_client.responses.parse(**payload) - else: - if self.include_response_headers: - raw_response = self.root_client.with_raw_response.responses.create( - **payload + raw_response = None + try: + if "response_format" in payload: + payload.pop("stream") + try: + raw_response = ( + self.root_client.chat.completions.with_raw_response.parse( + **payload + ) ) response = raw_response.parse() - generation_info = {"headers": dict(raw_response.headers)} + except openai.BadRequestError as e: + _handle_openai_bad_request(e) + elif self._use_responses_api(payload): + original_schema_obj = kwargs.get("response_format") + if original_schema_obj and _is_pydantic_class(original_schema_obj): + raw_response = self.root_client.responses.with_raw_response.parse( + **payload + ) else: - response = self.root_client.responses.create(**payload) - return _construct_lc_result_from_responses_api( - response, - schema=original_schema_obj, - metadata=generation_info, - output_version=self.output_version, - ) - elif self.include_response_headers: - raw_response = self.client.with_raw_response.create(**payload) - response = raw_response.parse() + raw_response = self.root_client.responses.with_raw_response.create( + **payload + ) + response = raw_response.parse() + if self.include_response_headers: + generation_info = {"headers": dict(raw_response.headers)} + return _construct_lc_result_from_responses_api( + response, + schema=original_schema_obj, + metadata=generation_info, + output_version=self.output_version, + ) + else: + raw_response = self.client.with_raw_response.create(**payload) + response = raw_response.parse() + except Exception as e: + if raw_response is not None and hasattr(raw_response, "http_response"): + e.response = raw_response.http_response # type: ignore[attr-defined] + raise e + if ( + self.include_response_headers + and raw_response is not None + and hasattr(raw_response, "headers") + ): generation_info = {"headers": dict(raw_response.headers)} - else: - response = self.client.create(**payload) return self._create_chat_result(response, generation_info) def _use_responses_api(self, payload: dict) -> bool: @@ -1375,46 +1384,55 @@ class BaseChatOpenAI(BaseChatModel): return await agenerate_from_stream(stream_iter) payload = self._get_request_payload(messages, stop=stop, **kwargs) generation_info = None - if "response_format" in payload: - if self.include_response_headers: - warnings.warn( - "Cannot currently include response headers when response_format is " - "specified." - ) - payload.pop("stream") - try: - response = await self.root_async_client.beta.chat.completions.parse( - **payload - ) - except openai.BadRequestError as e: - _handle_openai_bad_request(e) - elif self._use_responses_api(payload): - original_schema_obj = kwargs.get("response_format") - if original_schema_obj and _is_pydantic_class(original_schema_obj): - response = await self.root_async_client.responses.parse(**payload) - else: - if self.include_response_headers: + raw_response = None + try: + if "response_format" in payload: + payload.pop("stream") + try: + raw_response = await self.root_async_client.chat.completions.with_raw_response.parse( # noqa: E501 + **payload + ) + response = raw_response.parse() + except openai.BadRequestError as e: + _handle_openai_bad_request(e) + elif self._use_responses_api(payload): + original_schema_obj = kwargs.get("response_format") + if original_schema_obj and _is_pydantic_class(original_schema_obj): raw_response = ( - await self.root_async_client.with_raw_response.responses.create( + await self.root_async_client.responses.with_raw_response.parse( **payload ) ) - response = raw_response.parse() - generation_info = {"headers": dict(raw_response.headers)} else: - response = await self.root_async_client.responses.create(**payload) - return _construct_lc_result_from_responses_api( - response, - schema=original_schema_obj, - metadata=generation_info, - output_version=self.output_version, - ) - elif self.include_response_headers: - raw_response = await self.async_client.with_raw_response.create(**payload) - response = raw_response.parse() + raw_response = ( + await self.root_async_client.responses.with_raw_response.create( + **payload + ) + ) + response = raw_response.parse() + if self.include_response_headers: + generation_info = {"headers": dict(raw_response.headers)} + return _construct_lc_result_from_responses_api( + response, + schema=original_schema_obj, + metadata=generation_info, + output_version=self.output_version, + ) + else: + raw_response = await self.async_client.with_raw_response.create( + **payload + ) + response = raw_response.parse() + except Exception as e: + if raw_response is not None and hasattr(raw_response, "http_response"): + e.response = raw_response.http_response # type: ignore[attr-defined] + raise e + if ( + self.include_response_headers + and raw_response is not None + and hasattr(raw_response, "headers") + ): generation_info = {"headers": dict(raw_response.headers)} - else: - response = await self.async_client.create(**payload) return await run_in_executor( None, self._create_chat_result, response, generation_info ) diff --git a/libs/partners/openai/tests/cassettes/test_schema_parsing_failures.yaml.gz b/libs/partners/openai/tests/cassettes/test_schema_parsing_failures.yaml.gz new file mode 100644 index 00000000000..3f58e1a81b2 Binary files /dev/null and b/libs/partners/openai/tests/cassettes/test_schema_parsing_failures.yaml.gz differ diff --git a/libs/partners/openai/tests/cassettes/test_schema_parsing_failures_async.yaml.gz b/libs/partners/openai/tests/cassettes/test_schema_parsing_failures_async.yaml.gz new file mode 100644 index 00000000000..e9f29056c50 Binary files /dev/null and b/libs/partners/openai/tests/cassettes/test_schema_parsing_failures_async.yaml.gz differ diff --git a/libs/partners/openai/tests/cassettes/test_schema_parsing_failures_responses_api.yaml.gz b/libs/partners/openai/tests/cassettes/test_schema_parsing_failures_responses_api.yaml.gz new file mode 100644 index 00000000000..f833103aa33 Binary files /dev/null and b/libs/partners/openai/tests/cassettes/test_schema_parsing_failures_responses_api.yaml.gz differ diff --git a/libs/partners/openai/tests/cassettes/test_schema_parsing_failures_responses_api_async.yaml.gz b/libs/partners/openai/tests/cassettes/test_schema_parsing_failures_responses_api_async.yaml.gz new file mode 100644 index 00000000000..344e172f79c Binary files /dev/null and b/libs/partners/openai/tests/cassettes/test_schema_parsing_failures_responses_api_async.yaml.gz differ diff --git a/libs/partners/openai/tests/integration_tests/chat_models/test_base.py b/libs/partners/openai/tests/integration_tests/chat_models/test_base.py index b914af07cba..67177a2ef5b 100644 --- a/libs/partners/openai/tests/integration_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/integration_tests/chat_models/test_base.py @@ -27,7 +27,7 @@ from langchain_tests.integration_tests.chat_models import ( _validate_tool_call_message, magic_function, ) -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from langchain_openai import ChatOpenAI from tests.unit_tests.fake.callbacks import FakeCallbackHandler @@ -1155,3 +1155,62 @@ def test_prompt_cache_key_usage_methods_integration() -> None: response_model_level = chat_model_level.invoke(messages) assert isinstance(response_model_level, AIMessage) assert isinstance(response_model_level.content, str) + + +class BadModel(BaseModel): + response: str + + @field_validator("response") + @classmethod + def validate_response(cls, v: str) -> str: + if v != "bad": + raise ValueError('response must be exactly "bad"') + return v + + +# VCR can't handle parameterized tests +@pytest.mark.vcr() +def test_schema_parsing_failures() -> None: + llm = ChatOpenAI(model="gpt-5-nano", use_responses_api=False) + try: + llm.invoke("respond with good", response_format=BadModel) + except Exception as e: + assert e.response is not None # type: ignore[attr-defined] + else: + assert False + + +# VCR can't handle parameterized tests +@pytest.mark.vcr() +def test_schema_parsing_failures_responses_api() -> None: + llm = ChatOpenAI(model="gpt-5-nano", use_responses_api=True) + try: + llm.invoke("respond with good", response_format=BadModel) + except Exception as e: + assert e.response is not None # type: ignore[attr-defined] + else: + assert False + + +# VCR can't handle parameterized tests +@pytest.mark.vcr() +async def test_schema_parsing_failures_async() -> None: + llm = ChatOpenAI(model="gpt-5-nano", use_responses_api=False) + try: + await llm.ainvoke("respond with good", response_format=BadModel) + except Exception as e: + assert e.response is not None # type: ignore[attr-defined] + else: + assert False + + +# VCR can't handle parameterized tests +@pytest.mark.vcr() +async def test_schema_parsing_failures_responses_api_async() -> None: + llm = ChatOpenAI(model="gpt-5-nano", use_responses_api=True) + try: + await llm.ainvoke("respond with good", response_format=BadModel) + except Exception as e: + assert e.response is not None # type: ignore[attr-defined] + else: + assert False 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 68caee5d63f..2c33fe062bf 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 @@ -601,7 +601,7 @@ def test_openai_invoke(mock_client: MagicMock) -> None: # headers are not in response_metadata if include_response_headers not set assert "headers" not in res.response_metadata - assert mock_client.create.called + assert mock_client.with_raw_response.create.called async def test_openai_ainvoke(mock_async_client: AsyncMock) -> None: @@ -613,7 +613,7 @@ async def test_openai_ainvoke(mock_async_client: AsyncMock) -> None: # headers are not in response_metadata if include_response_headers not set assert "headers" not in res.response_metadata - assert mock_async_client.create.called + assert mock_async_client.with_raw_response.create.called @pytest.mark.parametrize( @@ -638,7 +638,7 @@ def test_openai_invoke_name(mock_client: MagicMock) -> None: with patch.object(llm, "client", mock_client): messages = [HumanMessage(content="Foo", name="Katie")] res = llm.invoke(messages) - call_args, call_kwargs = mock_client.create.call_args + call_args, call_kwargs = mock_client.with_raw_response.create.call_args assert len(call_args) == 0 # no positional args call_messages = call_kwargs["messages"] assert len(call_messages) == 1 @@ -678,7 +678,7 @@ def test_function_calls_with_tool_calls(mock_client: MagicMock) -> None: ] with patch.object(llm, "client", mock_client): _ = llm.invoke(messages) - _, call_kwargs = mock_client.create.call_args + _, call_kwargs = mock_client.with_raw_response.create.call_args call_messages = call_kwargs["messages"] tool_call_message_payload = call_messages[1] assert "tool_calls" in tool_call_message_payload @@ -688,7 +688,7 @@ def test_function_calls_with_tool_calls(mock_client: MagicMock) -> None: cast(AIMessage, messages[1]).tool_calls = [] with patch.object(llm, "client", mock_client): _ = llm.invoke(messages) - _, call_kwargs = mock_client.create.call_args + _, call_kwargs = mock_client.with_raw_response.create.call_args call_messages = call_kwargs["messages"] tool_call_message_payload = call_messages[1] assert "function_call" in tool_call_message_payload @@ -2326,8 +2326,9 @@ def test_mcp_tracing() -> None: tracer = FakeTracer() mock_client = MagicMock() - def mock_create(*args: Any, **kwargs: Any) -> Response: - return Response( + def mock_create(*args: Any, **kwargs: Any) -> MagicMock: + mock_raw_response = MagicMock() + mock_raw_response.parse.return_value = Response( id="resp_123", created_at=1234567890, model="o4-mini", @@ -2349,8 +2350,9 @@ def test_mcp_tracing() -> None: ) ], ) + return mock_raw_response - mock_client.responses.create = mock_create + mock_client.responses.with_raw_response.create = mock_create input_message = HumanMessage("Test query") tools = [ {