Skip to content

Commit 63485fa

Browse files
committed
fix: limit orphan validation to paired reasoning items, strip SDK-only fields
- validate_response_input now checks from the reasoning side: each reasoning item must be immediately followed by an assistant message. Standalone assistant messages are no longer flagged, fixing false positives in mixed histories. - get_response_input_items now uses __api_exclude__ and strips nested 'parsed' fields so ParsedResponse objects don't leak SDK-only data into next-turn input. Signed-off-by: majiayu000 <1835304752@qq.com>
1 parent 4333b1b commit 63485fa

2 files changed

Lines changed: 107 additions & 38 deletions

File tree

src/openai/lib/_response_input_builder.py

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ def get_response_input_items(response: Response) -> List[ResponseInputItemParam]
1818
consecutive pair when building the ``input`` for the next turn. Filtering
1919
out reasoning items (or re-ordering them) causes a 400 error from the API.
2020
21+
SDK-only fields (e.g. ``parsed`` from ``ParsedResponseOutputText``) are
22+
excluded so the returned dicts conform to ``ResponseInputItemParam``.
23+
2124
Example usage::
2225
2326
from openai.lib import get_response_input_items
@@ -35,17 +38,29 @@ def get_response_input_items(response: Response) -> List[ResponseInputItemParam]
3538
"""
3639
items: List[ResponseInputItemParam] = []
3740
for output_item in response.output:
38-
items.append(output_item.model_dump(exclude_unset=True)) # type: ignore[arg-type]
41+
data = output_item.model_dump(
42+
exclude_unset=True,
43+
exclude=getattr(output_item, "__api_exclude__", None),
44+
)
45+
# Strip SDK-only fields from nested content items
46+
# (e.g. ParsedResponseOutputText.parsed is not part of the API schema)
47+
for content_item in data.get("content", []):
48+
if isinstance(content_item, dict):
49+
content_item.pop("parsed", None)
50+
items.append(data) # type: ignore[arg-type]
3951
return items
4052

4153

4254
def validate_response_input(items: Sequence[Union[ResponseInputItemParam, object]]) -> None:
43-
"""Validate that reasoning+message pairs are not orphaned in an input list.
55+
"""Validate that reasoning+message pairs are intact in an input list.
56+
57+
Walks ``items`` and raises ``ValueError`` when it detects a ``reasoning``-type
58+
item that is NOT immediately followed by a ``message``-type item with
59+
role=assistant — the classic broken-pair pattern that causes a 400 from the
60+
API.
4461
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.
62+
Standalone assistant messages (those not part of a reasoning pair) are allowed
63+
and will not trigger a validation error.
4964
5065
This validator is a standalone opt-in helper. The primary recommendation is
5166
to build the input list with :func:`get_response_input_items` instead of
@@ -59,37 +74,30 @@ def validate_response_input(items: Sequence[Union[ResponseInputItemParam, object
5974
6075
from openai.lib import validate_response_input
6176
62-
validate_response_input(conversation) # raises ValueError if orphaned
77+
validate_response_input(conversation) # raises ValueError if broken pair
6378
response = client.responses.create(model="o3", input=conversation)
6479
"""
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-
7080
for i, item in enumerate(items):
71-
if _item_type(item) != "message":
81+
if _item_type(item) != "reasoning":
7282
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-
)
83+
# Each reasoning item must be immediately followed by an assistant message.
84+
next_idx = i + 1
85+
if next_idx < len(items):
86+
next_item = items[next_idx]
87+
if _item_type(next_item) == "message" and _item_role(next_item) == "assistant":
88+
continue
89+
item_id = _item_id(item)
90+
id_hint = f" (id={item_id!r})" if item_id else ""
91+
raise ValueError(
92+
f"Orphaned reasoning item{id_hint} detected: a 'reasoning' item "
93+
f"must be immediately followed by its paired 'message' item with "
94+
f"role='assistant'. The OpenAI Responses API requires that reasoning "
95+
f"and the immediately following assistant message are always passed "
96+
f"together as a consecutive pair. Either include the paired assistant "
97+
f"message directly after this reasoning item, or remove the reasoning "
98+
f"item. Use get_response_input_items() to build input lists that "
99+
f"preserve pairs automatically."
100+
)
93101

94102

95103
def _item_type(item: object) -> str:

tests/lib/test_response_input_builder.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,15 @@ def test_validate_passes_for_consecutive_pair_dicts() -> None:
8181
validate_response_input(items) # should not raise
8282

8383

84-
def test_validate_raises_for_orphaned_message_dicts() -> None:
85-
"""ValueError raised when assistant message is not preceded by reasoning (dict form)."""
84+
def test_validate_raises_for_orphaned_reasoning_dicts() -> None:
85+
"""ValueError raised when reasoning is not followed by assistant message (dict form)."""
8686
items = [
8787
{"type": "message", "role": "user", "content": "hello"},
8888
{"type": "reasoning", "id": "rs_1", "summary": []},
8989
{"type": "message", "role": "user", "content": "follow-up"},
9090
{"type": "message", "role": "assistant", "id": "msg_orphan", "content": []},
9191
]
92-
with pytest.raises(ValueError, match="msg_orphan"):
92+
with pytest.raises(ValueError, match="rs_1"):
9393
validate_response_input(items)
9494

9595

@@ -133,7 +133,7 @@ def test_validate_passes_with_object_form() -> None:
133133

134134

135135
def test_validate_raises_with_object_form_orphaned() -> None:
136-
"""Raises ValueError with object-form items when message is orphaned."""
136+
"""Raises ValueError with object-form items when reasoning is not followed by assistant."""
137137
reasoning = MagicMock()
138138
reasoning.type = "reasoning"
139139
reasoning.id = "rs_obj"
@@ -147,5 +147,66 @@ def test_validate_raises_with_object_form_orphaned() -> None:
147147
asst_msg.role = "assistant"
148148
asst_msg.id = "msg_orphan_obj"
149149

150-
with pytest.raises(ValueError, match="msg_orphan_obj"):
150+
with pytest.raises(ValueError, match="rs_obj"):
151151
validate_response_input([reasoning, user_msg, asst_msg])
152+
153+
154+
def test_validate_passes_for_standalone_assistant_with_later_pair() -> None:
155+
"""Standalone assistant message is valid even when a reasoning+assistant pair exists later."""
156+
items = [
157+
{"type": "message", "role": "assistant", "id": "msg_standalone", "content": []},
158+
{"type": "message", "role": "user", "content": "follow-up"},
159+
{"type": "reasoning", "id": "rs_1", "summary": []},
160+
{"type": "message", "role": "assistant", "id": "msg_paired", "content": []},
161+
]
162+
validate_response_input(items) # should not raise
163+
164+
165+
def test_validate_raises_for_reasoning_at_end() -> None:
166+
"""ValueError raised when reasoning item is the last item with no following message."""
167+
items = [
168+
{"type": "message", "role": "user", "content": "hello"},
169+
{"type": "reasoning", "id": "rs_trailing", "summary": []},
170+
]
171+
with pytest.raises(ValueError, match="rs_trailing"):
172+
validate_response_input(items)
173+
174+
175+
def test_get_response_input_items_excludes_parsed_fields() -> None:
176+
"""SDK-only 'parsed' field is stripped from nested content items."""
177+
message = MagicMock()
178+
message.type = "message"
179+
message.model_dump.return_value = {
180+
"type": "message",
181+
"role": "assistant",
182+
"id": "msg_parsed",
183+
"content": [
184+
{"type": "output_text", "text": "hello", "parsed": {"key": "value"}},
185+
],
186+
}
187+
response = _make_response([message])
188+
result = get_response_input_items(response)
189+
assert len(result) == 1
190+
assert "parsed" not in result[0]["content"][0] # type: ignore[index]
191+
192+
193+
def test_get_response_input_items_respects_api_exclude() -> None:
194+
"""SDK-only fields listed in __api_exclude__ are excluded from output."""
195+
full_data = {
196+
"type": "function_call",
197+
"id": "fc_1",
198+
"name": "my_func",
199+
"arguments": "{}",
200+
"parsed_arguments": {"key": "value"},
201+
}
202+
tool_call = MagicMock()
203+
tool_call.type = "function_call"
204+
tool_call.__api_exclude__ = {"parsed_arguments"}
205+
tool_call.model_dump.side_effect = lambda **kwargs: {
206+
k: v for k, v in full_data.items()
207+
if k not in (kwargs.get("exclude") or set())
208+
}
209+
response = _make_response([tool_call])
210+
result = get_response_input_items(response)
211+
assert len(result) == 1
212+
assert "parsed_arguments" not in result[0]

0 commit comments

Comments
 (0)