Skip to content

Commit 4333b1b

Browse files
committed
feat(lib): add get_response_input_items and validate_response_input helpers
The Responses API requires reasoning items and the immediately following assistant message to always be passed together as a consecutive pair in the next turn's input. The SDK provided no helper to extract output items correctly and no validation to surface this constraint before the 400 hits. Adds two opt-in helpers to src/openai/lib/_response_input_builder.py: - get_response_input_items(response): extracts response.output items ready to append to the next turn's input, preserving reasoning+message pairs. - validate_response_input(items): raises ValueError with a descriptive message when an orphaned assistant message is detected. Both are exported from openai.lib for easy import. Closes #3009 Signed-off-by: majiayu000 <1835304752@qq.com>
1 parent acd0c54 commit 4333b1b

3 files changed

Lines changed: 265 additions & 0 deletions

File tree

src/openai/lib/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
from ._tools import pydantic_function_tool as pydantic_function_tool
22
from ._parsing import ResponseFormatT as ResponseFormatT
3+
from ._response_input_builder import (
4+
validate_response_input as validate_response_input,
5+
get_response_input_items as get_response_input_items,
6+
)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
from typing import List, Union, Sequence
4+
5+
from ..types.responses.response import Response
6+
from ..types.responses.response_input_item_param import ResponseInputItemParam
7+
8+
9+
def get_response_input_items(response: Response) -> List[ResponseInputItemParam]:
10+
"""Extract output items from a Response to use as input for the next turn.
11+
12+
Iterates ``response.output`` in order and returns all items converted to
13+
``ResponseInputItemParam`` dicts, preserving the required reasoning+message
14+
consecutive pairing.
15+
16+
The Responses API requires that any ``reasoning`` item and the immediately
17+
following ``message`` (assistant) item are always passed together as a
18+
consecutive pair when building the ``input`` for the next turn. Filtering
19+
out reasoning items (or re-ordering them) causes a 400 error from the API.
20+
21+
Example usage::
22+
23+
from openai.lib import get_response_input_items
24+
25+
conversation: list = []
26+
for user_msg in turns:
27+
conversation.append({"role": "user", "content": user_msg})
28+
response = client.responses.create(
29+
model="o3",
30+
input=conversation,
31+
reasoning={"effort": "high"},
32+
)
33+
# Preserves reasoning+message pairs automatically:
34+
conversation.extend(get_response_input_items(response))
35+
"""
36+
items: List[ResponseInputItemParam] = []
37+
for output_item in response.output:
38+
items.append(output_item.model_dump(exclude_unset=True)) # type: ignore[arg-type]
39+
return items
40+
41+
42+
def validate_response_input(items: Sequence[Union[ResponseInputItemParam, object]]) -> None:
43+
"""Validate that reasoning+message pairs are not orphaned in an input list.
44+
45+
Walks ``items`` and raises ``ValueError`` when it detects a ``message``-type
46+
item (role=assistant) that is NOT immediately preceded by a ``reasoning``-type
47+
item, but where a ``reasoning`` item exists elsewhere in the list — the classic
48+
orphaning pattern that causes a 400 from the API.
49+
50+
This validator is a standalone opt-in helper. The primary recommendation is
51+
to build the input list with :func:`get_response_input_items` instead of
52+
filtering ``response.output`` manually.
53+
54+
Raises:
55+
ValueError: with a descriptive message that includes the offending item id
56+
and explains the constraint.
57+
58+
Example usage::
59+
60+
from openai.lib import validate_response_input
61+
62+
validate_response_input(conversation) # raises ValueError if orphaned
63+
response = client.responses.create(model="o3", input=conversation)
64+
"""
65+
has_reasoning = any(_item_type(item) == "reasoning" for item in items)
66+
if not has_reasoning:
67+
# No reasoning items at all — nothing to validate.
68+
return
69+
70+
for i, item in enumerate(items):
71+
if _item_type(item) != "message":
72+
continue
73+
role = _item_role(item)
74+
if role != "assistant":
75+
continue
76+
# This is an assistant message. It must be immediately preceded by a
77+
# reasoning item when reasoning items exist in the list.
78+
preceded_by_reasoning = i > 0 and _item_type(items[i - 1]) == "reasoning"
79+
if not preceded_by_reasoning:
80+
item_id = _item_id(item)
81+
id_hint = f" (id={item_id!r})" if item_id else ""
82+
raise ValueError(
83+
f"Orphaned assistant message{id_hint} detected: a 'message' item with "
84+
f"role='assistant' must be immediately preceded by its paired 'reasoning' "
85+
f"item when reasoning items are present in the input. "
86+
f"The OpenAI Responses API requires that reasoning and the immediately "
87+
f"following assistant message are always passed together as a consecutive "
88+
f"pair. Either include the paired reasoning item directly before this "
89+
f"message, use 'previous_response_id' to let the API manage context, or "
90+
f"build the input list with get_response_input_items() which preserves "
91+
f"pairs automatically."
92+
)
93+
94+
95+
def _item_type(item: object) -> str:
96+
if isinstance(item, dict):
97+
return str(item.get("type", ""))
98+
return str(getattr(item, "type", ""))
99+
100+
101+
def _item_role(item: object) -> str:
102+
if isinstance(item, dict):
103+
return str(item.get("role", ""))
104+
return str(getattr(item, "role", ""))
105+
106+
107+
def _item_id(item: object) -> str:
108+
if isinstance(item, dict):
109+
return str(item.get("id", ""))
110+
return str(getattr(item, "id", ""))
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
from unittest.mock import MagicMock
5+
6+
import pytest
7+
8+
from openai.lib import validate_response_input, get_response_input_items
9+
10+
11+
def _make_reasoning_item(item_id: str = "rs_001") -> Any:
12+
item = MagicMock()
13+
item.type = "reasoning"
14+
item.id = item_id
15+
item.model_dump.return_value = {"type": "reasoning", "id": item_id, "summary": []}
16+
return item
17+
18+
19+
def _make_message_item(item_id: str = "msg_001") -> Any:
20+
item = MagicMock()
21+
item.type = "message"
22+
item.role = "assistant"
23+
item.id = item_id
24+
item.model_dump.return_value = {"type": "message", "role": "assistant", "id": item_id, "content": []}
25+
return item
26+
27+
28+
def _make_response(output: list[Any]) -> Any:
29+
response = MagicMock()
30+
response.output = output
31+
return response
32+
33+
34+
# ---------------------------------------------------------------------------
35+
# get_response_input_items
36+
# ---------------------------------------------------------------------------
37+
38+
39+
def test_get_response_input_items_reasoning_and_message() -> None:
40+
"""Returns both reasoning and message items in order."""
41+
reasoning = _make_reasoning_item("rs_1")
42+
message = _make_message_item("msg_1")
43+
response = _make_response([reasoning, message])
44+
45+
result = get_response_input_items(response)
46+
47+
assert len(result) == 2
48+
assert result[0] == {"type": "reasoning", "id": "rs_1", "summary": []}
49+
assert result[1] == {"type": "message", "role": "assistant", "id": "msg_1", "content": []}
50+
51+
52+
def test_get_response_input_items_message_only() -> None:
53+
"""Returns message items when there are no reasoning items."""
54+
message = _make_message_item("msg_2")
55+
response = _make_response([message])
56+
57+
result = get_response_input_items(response)
58+
59+
assert len(result) == 1
60+
assert result[0] == {"type": "message", "role": "assistant", "id": "msg_2", "content": []}
61+
62+
63+
def test_get_response_input_items_empty() -> None:
64+
"""Returns empty list for empty output."""
65+
response = _make_response([])
66+
result = get_response_input_items(response)
67+
assert result == []
68+
69+
70+
# ---------------------------------------------------------------------------
71+
# validate_response_input
72+
# ---------------------------------------------------------------------------
73+
74+
75+
def test_validate_passes_for_consecutive_pair_dicts() -> None:
76+
"""No error when reasoning immediately precedes assistant message (dict form)."""
77+
items = [
78+
{"type": "reasoning", "id": "rs_1", "summary": []},
79+
{"type": "message", "role": "assistant", "id": "msg_1", "content": []},
80+
]
81+
validate_response_input(items) # should not raise
82+
83+
84+
def test_validate_raises_for_orphaned_message_dicts() -> None:
85+
"""ValueError raised when assistant message is not preceded by reasoning (dict form)."""
86+
items = [
87+
{"type": "message", "role": "user", "content": "hello"},
88+
{"type": "reasoning", "id": "rs_1", "summary": []},
89+
{"type": "message", "role": "user", "content": "follow-up"},
90+
{"type": "message", "role": "assistant", "id": "msg_orphan", "content": []},
91+
]
92+
with pytest.raises(ValueError, match="msg_orphan"):
93+
validate_response_input(items)
94+
95+
96+
def test_validate_raises_error_describes_constraint() -> None:
97+
"""Error message explains the reasoning+message pairing constraint."""
98+
items = [
99+
{"type": "reasoning", "id": "rs_1", "summary": []},
100+
{"type": "message", "role": "user", "content": "hi"},
101+
{"type": "message", "role": "assistant", "id": "msg_bad", "content": []},
102+
]
103+
with pytest.raises(ValueError, match="consecutive pair"):
104+
validate_response_input(items)
105+
106+
107+
def test_validate_passes_for_user_only_messages() -> None:
108+
"""No error when there are only user messages and no reasoning items."""
109+
items = [
110+
{"type": "message", "role": "user", "content": "hello"},
111+
{"type": "message", "role": "user", "content": "how are you"},
112+
]
113+
validate_response_input(items) # should not raise
114+
115+
116+
def test_validate_passes_for_empty_input() -> None:
117+
"""No error for empty input list."""
118+
validate_response_input([]) # should not raise
119+
120+
121+
def test_validate_passes_with_object_form() -> None:
122+
"""Works with object-form items (not dicts), consecutive pair."""
123+
reasoning = MagicMock()
124+
reasoning.type = "reasoning"
125+
reasoning.id = "rs_obj"
126+
127+
message = MagicMock()
128+
message.type = "message"
129+
message.role = "assistant"
130+
message.id = "msg_obj"
131+
132+
validate_response_input([reasoning, message]) # should not raise
133+
134+
135+
def test_validate_raises_with_object_form_orphaned() -> None:
136+
"""Raises ValueError with object-form items when message is orphaned."""
137+
reasoning = MagicMock()
138+
reasoning.type = "reasoning"
139+
reasoning.id = "rs_obj"
140+
141+
user_msg = MagicMock()
142+
user_msg.type = "message"
143+
user_msg.role = "user"
144+
145+
asst_msg = MagicMock()
146+
asst_msg.type = "message"
147+
asst_msg.role = "assistant"
148+
asst_msg.id = "msg_orphan_obj"
149+
150+
with pytest.raises(ValueError, match="msg_orphan_obj"):
151+
validate_response_input([reasoning, user_msg, asst_msg])

0 commit comments

Comments
 (0)