From 57c83d44bc8ae89a189ad521b9756cfac996039c Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 23 Jun 2026 12:26:24 -0400 Subject: [PATCH] fix(openrouter): strip Responses reasoning IDs (#38383) Closes #37777 --- OpenRouter can return OpenAI Responses reasoning item IDs such as `rs_*` in assistant reasoning details. Those IDs are not reliably resolvable on a later OpenRouter turn, so replaying them can make otherwise-valid multi-turn conversations fail with a provider 404. This keeps the useful reasoning payload while removing only the ephemeral Responses item IDs before serializing `reasoning_details` back into request history. Non-Responses IDs and reasoning text are left intact. --- .../langchain_openrouter/chat_models.py | 17 ++++++++- .../tests/unit_tests/test_chat_models.py | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 1aec0eea655..2eca53b1a0f 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -1125,6 +1125,19 @@ def _merge_reasoning_run(run: list[dict[str, Any]]) -> dict[str, Any]: return merged_entry +def _strip_ephemeral_reasoning_ids(value: Any) -> Any: + """Remove OpenAI Responses reasoning item IDs from outbound payloads.""" + if isinstance(value, list): + return [_strip_ephemeral_reasoning_ids(item) for item in value] + if isinstance(value, dict): + return { + key: _strip_ephemeral_reasoning_ids(item) + for key, item in value.items() + if not (key == "id" and isinstance(item, str) and item.startswith("rs_")) + } + return value + + def _merge_reasoning_details( details: list[dict[str, Any]], ) -> list[dict[str, Any]]: @@ -1244,8 +1257,8 @@ def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]: # noqa: C if "reasoning_content" in message.additional_kwargs: message_dict["reasoning"] = message.additional_kwargs["reasoning_content"] if "reasoning_details" in message.additional_kwargs: - message_dict["reasoning_details"] = _merge_reasoning_details( - message.additional_kwargs["reasoning_details"] + message_dict["reasoning_details"] = _strip_ephemeral_reasoning_ids( + _merge_reasoning_details(message.additional_kwargs["reasoning_details"]) ) elif isinstance(message, SystemMessage): message_dict = {"role": "system", "content": message.content} diff --git a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py index e5f698b3e8e..037dcf26416 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -1295,6 +1295,44 @@ class TestMessageConversion: result = _convert_message_to_dict(msg) assert result["reasoning_details"] == details + def test_ai_message_reasoning_details_strips_responses_ids(self) -> None: + """OpenAI Responses `rs_*` item IDs are stripped before replay.""" + response_id = "rs_053a05e24b0da75e0169fa358ea9fc81908b18aff8157798c1" + details = [ + { + "type": "reasoning.text", + "id": response_id, + "text": "step-by-step", + "index": 0, + } + ] + msg = AIMessage( + content="Answer", + additional_kwargs={"reasoning_details": details}, + ) + result = _convert_message_to_dict(msg) + assert result["reasoning_details"] == [ + {"type": "reasoning.text", "text": "step-by-step", "index": 0} + ] + assert response_id.startswith("rs_") + assert details[0]["id"] == response_id + + def test_ai_message_reasoning_details_preserves_non_responses_ids(self) -> None: + """Non-Responses IDs are preserved in reasoning details.""" + details = [ + { + "type": "reasoning.text", + "id": "reasoning_abc123", + "text": "step-by-step", + } + ] + msg = AIMessage( + content="Answer", + additional_kwargs={"reasoning_details": details}, + ) + result = _convert_message_to_dict(msg) + assert result["reasoning_details"] == details + def test_ai_message_unindexed_reasoning_details_not_merged(self) -> None: """Entries without an `index` are passed through unchanged.""" details = [