Skip to content

Commit 2c0acf0

Browse files
Fix streaming reasoning conversion to preserve extra fields (#11669)
Co-authored-by: David S. Batista <dsbatista@gmail.com>
1 parent d81d742 commit 2c0acf0

3 files changed

Lines changed: 165 additions & 27 deletions

File tree

haystack/components/generators/chat/openai_responses.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,7 @@ def _convert_response_to_chat_message(responses: Response | ParsedResponse) -> C
629629
)
630630

631631

632-
def _convert_response_chunk_to_streaming_chunk(
632+
def _convert_response_chunk_to_streaming_chunk( # noqa: PLR0911
633633
chunk: ResponseStreamEvent, previous_chunks: list[StreamingChunk], component_info: ComponentInfo | None = None
634634
) -> StreamingChunk:
635635
"""
@@ -669,6 +669,21 @@ def _convert_response_chunk_to_streaming_chunk(
669669
meta={"received_at": datetime.now().isoformat()},
670670
)
671671

672+
elif chunk.type == "response.output_item.done":
673+
# The done event carries the completed reasoning item, which includes encrypted_content
674+
# when include=["reasoning.encrypted_content"] was requested. Without this handler the
675+
# event falls through to the generic default and reasoning=None, so encrypted_content
676+
# is never available for multi-turn conversations.
677+
if chunk.item.type == "reasoning":
678+
reasoning = ReasoningContent(reasoning_text="", extra=chunk.item.to_dict())
679+
return StreamingChunk(
680+
content="",
681+
component_info=component_info,
682+
index=chunk.output_index,
683+
reasoning=reasoning,
684+
meta={"received_at": datetime.now().isoformat()},
685+
)
686+
672687
elif chunk.type == "response.completed":
673688
# This means a full response is finished
674689
# If there are tool_calls present in the final output we mark finish_reason as tool_calls otherwise it's
@@ -812,7 +827,16 @@ def _convert_streaming_chunks_to_chat_message(chunks: list[StreamingChunk]) -> C
812827
# function calls without reasoning ids are not supported by the API
813828
reasoning = None
814829
if reasoning_id:
815-
reasoning = ReasoningContent(reasoning_text=reasoning_text, extra={"id": reasoning_id, "type": "reasoning"})
830+
# Preserve all extra fields from streaming chunks (e.g. encrypted_content) while ensuring id and
831+
# type are present
832+
reasoning_extra = {}
833+
for chunk in chunks:
834+
if chunk.reasoning and chunk.reasoning.extra:
835+
reasoning_extra.update(chunk.reasoning.extra)
836+
# Ensure id and type are always set, but don't override if already present
837+
reasoning_extra.setdefault("id", reasoning_id)
838+
reasoning_extra.setdefault("type", "reasoning")
839+
reasoning = ReasoningContent(reasoning_text=reasoning_text, extra=reasoning_extra)
816840

817841
return ChatMessage.from_assistant(
818842
text=text or None, tool_calls=tool_calls, meta=final_response, reasoning=reasoning
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed ``OpenAIResponsesChatGenerator`` dropping ``encrypted_content`` from reasoning items in streaming mode.
5+
Two issues were addressed: ``_convert_streaming_chunks_to_chat_message`` was discarding all reasoning extras
6+
except ``id`` and ``type``; and ``_convert_response_chunk_to_streaming_chunk`` had no handler for the
7+
``response.output_item.done`` event for reasoning items, which is the only event that carries
8+
``encrypted_content``. Both are now fixed, so ``encrypted_content`` is preserved end-to-end and
9+
multi-turn conversations with ``store=False`` and ``include=["reasoning.encrypted_content"]`` work correctly.

test/components/generators/chat/test_openai_responses_conversion.py

Lines changed: 130 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,16 @@ def openai_responses_streaming_chunks_with_tool_call():
9797
),
9898
StreamingChunk(
9999
content="",
100-
meta={
101-
"item": {
100+
meta={"received_at": ANY},
101+
index=0,
102+
reasoning=ReasoningContent(
103+
reasoning_text="",
104+
extra={
102105
"id": "rs_095b57053855eac100690491f54e308196878239be3ba6133c",
103106
"summary": [],
104107
"type": "reasoning",
105108
},
106-
"output_index": 0,
107-
"sequence_number": 3,
108-
"type": "response.output_item.done",
109-
"received_at": ANY,
110-
},
111-
index=0,
109+
),
112110
),
113111
StreamingChunk(
114112
content="",
@@ -233,7 +231,11 @@ def test_convert_streaming_chunks_to_chat_message_with_tool_call_empty_reasoning
233231
_content=[
234232
ReasoningContent(
235233
reasoning_text="",
236-
extra={"id": "rs_095b57053855eac100690491f54e308196878239be3ba6133c", "type": "reasoning"},
234+
extra={
235+
"id": "rs_095b57053855eac100690491f54e308196878239be3ba6133c",
236+
"summary": [],
237+
"type": "reasoning",
238+
},
237239
),
238240
ToolCall(
239241
tool_name="weather",
@@ -609,18 +611,16 @@ def test_convert_only_text(self):
609611
),
610612
StreamingChunk(
611613
content="",
612-
meta={
613-
"received_at": ANY,
614-
"item": {
614+
meta={"received_at": ANY},
615+
index=0,
616+
reasoning=ReasoningContent(
617+
reasoning_text="",
618+
extra={
615619
"id": "rs_0a8811e62a95217b00690c5ff70a308195a8207d7eb43f1d5b",
616620
"summary": [],
617621
"type": "reasoning",
618622
},
619-
"output_index": 0,
620-
"sequence_number": 3,
621-
"type": "response.output_item.done",
622-
},
623-
index=0,
623+
),
624624
),
625625
StreamingChunk(
626626
content="",
@@ -1075,18 +1075,16 @@ def test_convert_only_function_call(self):
10751075
),
10761076
StreamingChunk(
10771077
content="",
1078-
meta={
1079-
"item": {
1078+
meta={"received_at": ANY},
1079+
index=0,
1080+
reasoning=ReasoningContent(
1081+
reasoning_text="",
1082+
extra={
10801083
"id": "rs_095b57053855eac100690491f54e308196878239be3ba6133c",
10811084
"summary": [],
10821085
"type": "reasoning",
10831086
},
1084-
"output_index": 0,
1085-
"sequence_number": 3,
1086-
"type": "response.output_item.done",
1087-
"received_at": ANY,
1088-
},
1089-
index=0,
1087+
),
10901088
),
10911089
StreamingChunk(
10921090
content="",
@@ -1437,3 +1435,110 @@ def test_convert_invalid(self):
14371435
)
14381436
with pytest.raises(ValueError):
14391437
_convert_chat_message_to_responses_api_format(message)
1438+
1439+
def test_convert_streaming_chunks_to_chat_message_preserves_encrypted_content(self):
1440+
"""Test that encrypted_content in reasoning extra is preserved during streaming conversion."""
1441+
chunks = [
1442+
StreamingChunk(
1443+
content="",
1444+
meta={"received_at": ANY},
1445+
index=0,
1446+
start=True,
1447+
reasoning=ReasoningContent(
1448+
reasoning_text="",
1449+
extra={
1450+
"id": "rs_095b57053855eac100690491f54e308196878239be3ba6133c",
1451+
"type": "reasoning",
1452+
"encrypted_content": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", # Simulated encrypted reasoning
1453+
"status": "in_progress",
1454+
},
1455+
),
1456+
),
1457+
StreamingChunk(
1458+
content="",
1459+
meta={
1460+
"received_at": ANY,
1461+
"response": {
1462+
"id": "resp_095b57053855eac100690491f4e22c8196ac124365e8c70424",
1463+
"created_at": 1761907188.0,
1464+
"model": "gpt-5-mini-2025-08-07",
1465+
"object": "response",
1466+
"output": [
1467+
{
1468+
"id": "rs_095b57053855eac100690491f54e308196878239be3ba6133c",
1469+
"type": "reasoning",
1470+
"encrypted_content": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
1471+
"status": "completed",
1472+
}
1473+
],
1474+
},
1475+
"sequence_number": 16,
1476+
"type": "response.completed",
1477+
},
1478+
finish_reason="stop",
1479+
),
1480+
]
1481+
1482+
message = _convert_streaming_chunks_to_chat_message(chunks)
1483+
1484+
# Verify reasoning content exists and has the correct structure
1485+
assert message.reasoning is not None
1486+
assert message.reasoning.reasoning_text == ""
1487+
1488+
# Verify encrypted_content is preserved along with id and type
1489+
assert message.reasoning.extra.get("id") == "rs_095b57053855eac100690491f54e308196878239be3ba6133c"
1490+
assert message.reasoning.extra.get("type") == "reasoning"
1491+
assert message.reasoning.extra.get("encrypted_content") == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
1492+
assert message.reasoning.extra.get("status") == "in_progress"
1493+
1494+
def test_encrypted_content_preserved_through_full_streaming_pipeline(self):
1495+
"""
1496+
Feeds real OpenAI event objects through the full pipeline:
1497+
1498+
_convert_response_chunk_to_streaming_chunk → _convert_streaming_chunks_to_chat_message
1499+
1500+
to verify encrypted_content survives end-to-end.
1501+
"""
1502+
REASONING_ID = "rs_abc123"
1503+
ENCRYPTED = "eyJhbGciOiJIUzI1NiJ9.encrypted_reasoning"
1504+
1505+
openai_events = [
1506+
# reasoning item starts — encrypted_content not yet available
1507+
ResponseOutputItemAddedEvent(
1508+
item=ResponseReasoningItem(id=REASONING_ID, summary=[], type="reasoning", status="in_progress"),
1509+
output_index=0,
1510+
sequence_number=0,
1511+
type="response.output_item.added",
1512+
),
1513+
# reasoning item finishes — encrypted_content is now populated
1514+
ResponseOutputItemDoneEvent(
1515+
item=ResponseReasoningItem(
1516+
id=REASONING_ID, summary=[], type="reasoning", encrypted_content=ENCRYPTED, status="completed"
1517+
),
1518+
output_index=0,
1519+
sequence_number=1,
1520+
type="response.output_item.done",
1521+
),
1522+
]
1523+
1524+
streaming_chunks = []
1525+
for event in openai_events:
1526+
chunk = _convert_response_chunk_to_streaming_chunk(event, previous_chunks=streaming_chunks)
1527+
streaming_chunks.append(chunk)
1528+
1529+
# The done chunk must carry reasoning so encrypted_content reaches the assembly step
1530+
done_chunk = streaming_chunks[1]
1531+
assert done_chunk.reasoning is not None, (
1532+
"response.output_item.done for reasoning must produce a StreamingChunk with reasoning set; "
1533+
"without this, encrypted_content is silently dropped before assembly"
1534+
)
1535+
assert done_chunk.reasoning.extra.get("encrypted_content") == ENCRYPTED
1536+
1537+
message = _convert_streaming_chunks_to_chat_message(streaming_chunks)
1538+
1539+
assert message.reasoning is not None
1540+
assert message.reasoning.extra.get("id") == REASONING_ID
1541+
assert message.reasoning.extra.get("encrypted_content") == ENCRYPTED, (
1542+
"encrypted_content was dropped — the response.output_item.done event for reasoning items "
1543+
"must be handled in _convert_response_chunk_to_streaming_chunk"
1544+
)

0 commit comments

Comments
 (0)