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.
This commit is contained in:
Mason Daugherty
2026-06-23 12:26:24 -04:00
committed by GitHub
parent 95fe150ad2
commit 57c83d44bc
2 changed files with 53 additions and 2 deletions

View File

@@ -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}

View File

@@ -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 = [