Skip to content

Commit 792c1a7

Browse files
authored
fix(openai): drop response item ids when store is false (#38372)
Some Responses API conversations can safely replay prior response item IDs because the server stored those items. That assumption breaks when `store=False`: prior `rs_*` reasoning items and `msg_*` assistant message IDs are not available on the server for the next turn, so replaying them can crash with `Item with id 'rs_...' not found` or similar item lookup errors. This updates the Responses API payload builder to treat `store=False` as a stateless replay mode. The visible assistant text is still preserved in history, but server-side response item IDs are not sent back unless they are usable without server persistence. In practical terms: - Bare `rs_*` reasoning items are dropped for `store=False` because they only reference server-side state that was not stored. - Reasoning items with `encrypted_content` are preserved because OpenAI uses them as the stateless/ZDR way to carry reasoning context forward. - Prior assistant `msg_*` IDs are omitted for `store=False`; the assistant message is replayed as ordinary assistant text instead of as a reference to a stored server item. Dropping `msg_*` IDs in this case should not remove useful user-visible context: the text content remains in the request. It only removes an item identity that the server cannot reliably resolve when `store=False`. Persisted `store=True` Responses flows continue to replay item IDs as before. The regression test mirrors the minimal user story: make one Responses/Codex call, reuse the returned `AIMessage` in a follow-up request, and verify the next payload keeps the visible assistant message and encrypted reasoning context while omitting unresolvable bare item references.
1 parent 9f7e46f commit 792c1a7

7 files changed

Lines changed: 223 additions & 9 deletions

File tree

libs/partners/openai/langchain_openai/chat_models/base.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4183,7 +4183,9 @@ def _construct_responses_api_payload(
41834183
):
41844184
payload.pop("temperature", None)
41854185

4186-
payload["input"] = _construct_responses_api_input(messages)
4186+
payload["input"] = _construct_responses_api_input(
4187+
messages, store=payload.get("store")
4188+
)
41874189
if tools := payload.pop("tools", None):
41884190
new_tools: list = []
41894191
for tool in tools:
@@ -4415,8 +4417,29 @@ def _pop_index_and_sub_index(block: dict) -> dict:
44154417
return new_block
44164418

44174419

4418-
def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
4419-
"""Construct the input for the OpenAI Responses API."""
4420+
def _construct_responses_api_input(
4421+
messages: Sequence[BaseMessage], *, store: bool | None = None
4422+
) -> list:
4423+
"""Construct the input for the OpenAI Responses API.
4424+
4425+
Args:
4426+
messages: Conversation history to serialize into Responses API input items.
4427+
store: Mirrors the request's `store` flag, controlling stateless-replay
4428+
behavior.
4429+
4430+
When `False`, the server does not persist response items, so
4431+
previously returned item IDs cannot be resolved on the next turn.
4432+
Assistant message IDs (`msg_*`) are therefore omitted and reasoning
4433+
blocks are dropped unless they carry `encrypted_content` (which can be
4434+
replayed without server-side storage).
4435+
4436+
When `True` or `None` (the default, matching the server's
4437+
stored-by-default behavior), item IDs and reasoning blocks
4438+
are preserved.
4439+
4440+
Returns:
4441+
A list of Responses API input items derived from `messages`.
4442+
"""
44204443
input_ = []
44214444
for lc_msg in messages:
44224445
if isinstance(lc_msg, AIMessage):
@@ -4505,13 +4528,16 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
45054528
"type": "message",
45064529
"content": [new_block],
45074530
"role": "assistant",
4508-
"id": msg_id,
45094531
}
4532+
if store is not False:
4533+
new_item["id"] = msg_id
45104534
if phase is not None:
45114535
new_item["phase"] = phase
45124536
input_.append(new_item)
4537+
elif block_type == "reasoning":
4538+
if store is not False or "encrypted_content" in block:
4539+
input_.append(_pop_index_and_sub_index(block))
45134540
elif block_type in (
4514-
"reasoning",
45154541
"compaction",
45164542
"web_search_call",
45174543
"file_search_call",
@@ -4530,9 +4556,17 @@ def _construct_responses_api_input(messages: Sequence[BaseMessage]) -> list:
45304556
input_.append(_pop_index_and_sub_index(block))
45314557
elif block_type == "image_generation_call":
45324558
# A previous image generation call can be referenced by ID
4533-
input_.append(
4534-
{"type": "image_generation_call", "id": block["id"]}
4535-
)
4559+
if store is not False:
4560+
input_.append(
4561+
{"type": "image_generation_call", "id": block["id"]}
4562+
)
4563+
else:
4564+
# ID-only references require stored server state. For
4565+
# stateless requests, replay full items and drop bare
4566+
# references that the backend cannot resolve.
4567+
image_generation_call = _pop_index_and_sub_index(block)
4568+
if set(image_generation_call) - {"type", "id"}:
4569+
input_.append(image_generation_call)
45364570
else:
45374571
pass
45384572
elif isinstance(msg.get("content"), str):
Binary file not shown.
Binary file not shown.
-32 Bytes
Binary file not shown.

libs/partners/openai/tests/unit_tests/chat_models/test_base.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2771,6 +2771,103 @@ def test__construct_responses_api_input_human_message_with_image_url_conversion(
27712771
assert result[0]["content"][1]["detail"] == "high"
27722772

27732773

2774+
def test__construct_responses_api_input_store_false_replays_stateless_history() -> None:
2775+
ai_message = AIMessage(
2776+
content=[
2777+
{"type": "reasoning", "id": "rs_123", "summary": []},
2778+
{
2779+
"type": "reasoning",
2780+
"id": "rs_456",
2781+
"summary": [],
2782+
"encrypted_content": "",
2783+
},
2784+
{"type": "text", "text": "Use pathlib.rglob.", "id": "msg_123"},
2785+
],
2786+
response_metadata={"id": "resp_123"},
2787+
)
2788+
2789+
result = _construct_responses_api_input([ai_message], store=False)
2790+
2791+
assert result == [
2792+
{
2793+
"type": "reasoning",
2794+
"id": "rs_456",
2795+
"summary": [],
2796+
"encrypted_content": "",
2797+
},
2798+
{
2799+
"type": "message",
2800+
"content": [
2801+
{"type": "output_text", "text": "Use pathlib.rglob.", "annotations": []}
2802+
],
2803+
"role": "assistant",
2804+
},
2805+
]
2806+
2807+
2808+
@pytest.mark.parametrize("store", [None, True])
2809+
def test__construct_responses_api_input_store_enabled_keeps_item_ids(
2810+
store: bool | None,
2811+
) -> None:
2812+
ai_message = AIMessage(
2813+
content=[
2814+
{"type": "reasoning", "id": "rs_123", "summary": []},
2815+
{"type": "text", "text": "Use pathlib.rglob.", "id": "msg_123"},
2816+
],
2817+
response_metadata={"id": "resp_123"},
2818+
)
2819+
2820+
result = _construct_responses_api_input([ai_message], store=store)
2821+
2822+
assert result == [
2823+
{"type": "reasoning", "id": "rs_123", "summary": []},
2824+
{
2825+
"type": "message",
2826+
"content": [
2827+
{"type": "output_text", "text": "Use pathlib.rglob.", "annotations": []}
2828+
],
2829+
"role": "assistant",
2830+
"id": "msg_123",
2831+
},
2832+
]
2833+
2834+
2835+
def test__construct_responses_api_input_store_false_keeps_full_tool_call_items() -> (
2836+
None
2837+
):
2838+
ai_message = AIMessage(
2839+
content=[
2840+
{
2841+
"type": "function_call",
2842+
"name": "get_weather",
2843+
"arguments": '{"location": "San Francisco"}',
2844+
"call_id": "call_123",
2845+
"id": "fc_456",
2846+
}
2847+
],
2848+
tool_calls=[
2849+
{
2850+
"id": "call_123",
2851+
"name": "get_weather",
2852+
"args": {"location": "San Francisco"},
2853+
"type": "tool_call",
2854+
}
2855+
],
2856+
)
2857+
2858+
result = _construct_responses_api_input([ai_message], store=False)
2859+
2860+
assert result == [
2861+
{
2862+
"type": "function_call",
2863+
"name": "get_weather",
2864+
"arguments": '{"location": "San Francisco"}',
2865+
"call_id": "call_123",
2866+
"id": "fc_456",
2867+
}
2868+
]
2869+
2870+
27742871
def test__construct_responses_api_input_ai_message_with_tool_calls() -> None:
27752872
"""Test that AI messages with tool calls are properly converted."""
27762873
tool_calls = [

libs/partners/openai/tests/unit_tests/chat_models/test_codex.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Any
88

99
import pytest
10-
from langchain_core.messages import ChatMessage, HumanMessage, SystemMessage
10+
from langchain_core.messages import AIMessage, ChatMessage, HumanMessage, SystemMessage
1111

1212
import langchain_openai.chat_models.codex as codex_module
1313
from langchain_openai.chat_models.base import ChatOpenAI
@@ -289,6 +289,89 @@ def test_request_payload_sends_store_false_and_stream_true() -> None:
289289
assert payload["stream"] is True
290290

291291

292+
def test_request_payload_with_store_false_drops_reasoning_item_references() -> None:
293+
model = _build_model()
294+
payload = model._get_request_payload(
295+
[
296+
HumanMessage("Explain recursive file search."),
297+
AIMessage(
298+
content=[
299+
{"type": "reasoning", "id": "rs_123", "summary": []},
300+
{
301+
"type": "reasoning",
302+
"id": "rs_456",
303+
"summary": [],
304+
"encrypted_content": "encrypted-context",
305+
},
306+
{"type": "text", "text": "Use pathlib.rglob.", "id": "msg_123"},
307+
],
308+
response_metadata={"id": "resp_123"},
309+
),
310+
HumanMessage("Make it shorter."),
311+
]
312+
)
313+
314+
assert payload["store"] is False
315+
assert [item.get("type") for item in payload["input"]] == [
316+
"message",
317+
"reasoning",
318+
"message",
319+
"message",
320+
]
321+
assert payload["input"][1] == {
322+
"type": "reasoning",
323+
"id": "rs_456",
324+
"summary": [],
325+
"encrypted_content": "encrypted-context",
326+
}
327+
assert payload["input"][2]["content"] == [
328+
{"type": "output_text", "text": "Use pathlib.rglob.", "annotations": []}
329+
]
330+
assert "id" not in payload["input"][2]
331+
332+
333+
def test_request_payload_with_store_false_drops_image_call_references() -> None:
334+
model = _build_model()
335+
payload = model._get_request_payload(
336+
[
337+
HumanMessage("Draw a word."),
338+
AIMessage(
339+
content=[
340+
{"type": "image_generation_call", "id": "ig_reference_only"},
341+
{
342+
"type": "image_generation_call",
343+
"id": "ig_with_result",
344+
"result": "base64-image",
345+
"status": "completed",
346+
"index": 0,
347+
},
348+
{"type": "text", "text": "Done.", "id": "msg_123"},
349+
],
350+
response_metadata={"id": "resp_123"},
351+
),
352+
HumanMessage("Change the color."),
353+
]
354+
)
355+
356+
assert payload["store"] is False
357+
assert [item.get("type") for item in payload["input"]] == [
358+
"message",
359+
"image_generation_call",
360+
"message",
361+
"message",
362+
]
363+
assert payload["input"][1] == {
364+
"type": "image_generation_call",
365+
"id": "ig_with_result",
366+
"result": "base64-image",
367+
"status": "completed",
368+
}
369+
assert payload["input"][2]["content"] == [
370+
{"type": "output_text", "text": "Done.", "annotations": []}
371+
]
372+
assert "id" not in payload["input"][2]
373+
374+
292375
def test_request_payload_sets_default_instructions() -> None:
293376
"""The Codex backend 400s without `instructions`; default must be injected."""
294377
model = _build_model()

0 commit comments

Comments
 (0)