diff --git a/libs/langchain/tests/unit_tests/chat_models/test_base.py b/libs/langchain/tests/unit_tests/chat_models/test_base.py index cdf7c680f34..55c210dba09 100644 --- a/libs/langchain/tests/unit_tests/chat_models/test_base.py +++ b/libs/langchain/tests/unit_tests/chat_models/test_base.py @@ -138,6 +138,7 @@ def test_configurable() -> None: "extra_body": None, "include_response_headers": False, "stream_usage": False, + "use_previous_response_id": False, "use_responses_api": None, }, "kwargs": { diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 8f3f3a61bf8..d2585b37fac 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -607,6 +607,40 @@ class BaseChatOpenAI(BaseChatModel): .. versionadded:: 0.3.24 """ + use_previous_response_id: bool = False + """If True, always pass ``previous_response_id`` using the ID of the most recent + response. Responses API only. + + Input messages up to the most recent response will be dropped from request + payloads. + + For example, the following two are equivalent: + + .. code-block:: python + + llm = ChatOpenAI( + model="o4-mini", + use_previous_response_id=True, + ) + llm.invoke( + [ + HumanMessage("Hello"), + AIMessage("Hi there!", response_metadata={"id": "resp_123"}), + HumanMessage("How are you?"), + ] + ) + + .. code-block:: python + + llm = ChatOpenAI( + model="o4-mini", + use_responses_api=True, + ) + llm.invoke([HumanMessage("How are you?")], previous_response_id="resp_123") + + .. versionadded:: 0.3.26 + """ + use_responses_api: Optional[bool] = None """Whether to use the Responses API instead of the Chat API. @@ -1081,6 +1115,8 @@ class BaseChatOpenAI(BaseChatModel): return True elif self.truncation is not None: return True + elif self.use_previous_response_id: + return True else: return _use_responses_api(payload) @@ -1097,7 +1133,14 @@ class BaseChatOpenAI(BaseChatModel): payload = {**self._default_params, **kwargs} if self._use_responses_api(payload): - payload = _construct_responses_api_payload(messages, payload) + if self.use_previous_response_id: + last_messages, previous_response_id = _get_last_messages(messages) + payload_to_use = last_messages if previous_response_id else messages + if previous_response_id: + payload["previous_response_id"] = previous_response_id + payload = _construct_responses_api_payload(payload_to_use, payload) + else: + payload = _construct_responses_api_payload(messages, payload) else: payload["messages"] = [_convert_message_to_dict(m) for m in messages] return payload @@ -3202,6 +3245,30 @@ def _use_responses_api(payload: dict) -> bool: return bool(uses_builtin_tools or responses_only_args.intersection(payload)) +def _get_last_messages( + messages: Sequence[BaseMessage], +) -> tuple[Sequence[BaseMessage], Optional[str]]: + """ + Return + 1. Every message after the most-recent AIMessage that has a non-empty + ``response_metadata["id"]`` (may be an empty list), + 2. That id. + + If the most-recent AIMessage does not have an id (or there is no + AIMessage at all) the entire conversation is returned together with ``None``. + """ + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if isinstance(msg, AIMessage): + response_id = msg.response_metadata.get("id") + if response_id: + return messages[i + 1 :], response_id + else: + return messages, None + + return messages, None + + def _construct_responses_api_payload( messages: Sequence[BaseMessage], payload: dict ) -> dict: 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 c9fce46bc9e..37eaf500e74 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 @@ -12,6 +12,7 @@ from langchain_core.load import dumps, loads from langchain_core.messages import ( AIMessage, AIMessageChunk, + BaseMessage, FunctionMessage, HumanMessage, InvalidToolCall, @@ -59,6 +60,7 @@ from langchain_openai.chat_models.base import ( _convert_to_openai_response_format, _create_usage_metadata, _format_message_content, + _get_last_messages, _oai_structured_outputs_parser, ) @@ -2151,3 +2153,102 @@ def test_compat() -> None: message_v03_output = _convert_to_v03_ai_message(message) assert message_v03_output == message_v03 assert message_v03_output is not message_v03 + + +def test_get_last_messages() -> None: + messages: list[BaseMessage] = [HumanMessage("Hello")] + last_messages, previous_response_id = _get_last_messages(messages) + assert last_messages == [HumanMessage("Hello")] + assert previous_response_id is None + + messages = [ + HumanMessage("Hello"), + AIMessage("Hi there!", response_metadata={"id": "resp_123"}), + HumanMessage("How are you?"), + ] + + last_messages, previous_response_id = _get_last_messages(messages) + assert last_messages == [HumanMessage("How are you?")] + assert previous_response_id == "resp_123" + + messages = [ + HumanMessage("Hello"), + AIMessage("Hi there!", response_metadata={"id": "resp_123"}), + HumanMessage("How are you?"), + AIMessage("Well thanks.", response_metadata={"id": "resp_456"}), + HumanMessage("Great."), + ] + last_messages, previous_response_id = _get_last_messages(messages) + assert last_messages == [HumanMessage("Great.")] + assert previous_response_id == "resp_456" + + messages = [ + HumanMessage("Hello"), + AIMessage("Hi there!", response_metadata={"id": "resp_123"}), + HumanMessage("What's the weather?"), + AIMessage( + "", + response_metadata={"id": "resp_456"}, + tool_calls=[ + { + "type": "tool_call", + "name": "get_weather", + "id": "call_123", + "args": {"location": "San Francisco"}, + } + ], + ), + ToolMessage("It's sunny.", tool_call_id="call_123"), + ] + last_messages, previous_response_id = _get_last_messages(messages) + assert last_messages == [ToolMessage("It's sunny.", tool_call_id="call_123")] + assert previous_response_id == "resp_456" + + messages = [ + HumanMessage("Hello"), + AIMessage("Hi there!", response_metadata={"id": "resp_123"}), + HumanMessage("How are you?"), + AIMessage("Well thanks.", response_metadata={"id": "resp_456"}), + HumanMessage("Good."), + HumanMessage("Great."), + ] + last_messages, previous_response_id = _get_last_messages(messages) + assert last_messages == [HumanMessage("Good."), HumanMessage("Great.")] + assert previous_response_id == "resp_456" + + messages = [ + HumanMessage("Hello"), + AIMessage("Hi there!", response_metadata={"id": "resp_123"}), + ] + last_messages, response_id = _get_last_messages(messages) + assert last_messages == [] + assert response_id == "resp_123" + + +def test_get_request_payload_use_previous_response_id() -> None: + # Default - don't use previous_response ID + llm = ChatOpenAI(model="o4-mini", use_responses_api=True) + messages = [ + HumanMessage("Hello"), + AIMessage("Hi there!", response_metadata={"id": "resp_123"}), + HumanMessage("How are you?"), + ] + payload = llm._get_request_payload(messages) + assert "previous_response_id" not in payload + assert len(payload["input"]) == 3 + + # Use previous response ID + llm = ChatOpenAI( + model="o4-mini", + # Specifying use_previous_response_id automatically engages Responses API + use_previous_response_id=True, + ) + payload = llm._get_request_payload(messages) + assert payload["previous_response_id"] == "resp_123" + assert len(payload["input"]) == 1 + + # Check single message + messages = [HumanMessage("Hello")] + payload = llm._get_request_payload(messages) + assert "previous_response_id" not in payload + assert len(payload["input"]) == 1