Skip to content

Commit 5c6be70

Browse files
committed
fix: prevent streaming response duplication with custom OpenAI endpoints
When streaming, _process_chunk emits both an add_message event (to initialise the chat bubble) and a token event for each chunk. Consumers that process both event types — such as the OpenAI Responses API endpoint — end up yielding the same content twice: once from the add_message text and once from the token data. Root-cause fix: set the first-chunk add_message text to an empty string so streamed content is delivered exclusively through token events. Defensive fix: track whether token events have been received in the OpenAI Responses streaming handler and skip add_message text content when tokens are active, preventing duplication even if the root cause fix is bypassed. Fixes #10719
1 parent 6ed0091 commit 5c6be70

3 files changed

Lines changed: 30 additions & 9 deletions

File tree

src/backend/base/langflow/api/v1/openai_responses.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ async def openai_stream_generator() -> AsyncGenerator[str, None]:
132132
tool_call_counter = 0
133133
processed_tools = set() # Track processed tool calls to avoid duplicates
134134
previous_content = "" # Track content already sent to calculate deltas
135+
has_token_events = False # Track whether token events are being received
135136

136137
async for event_data in consume_and_yield(asyncio_queue, asyncio_queue_client_consumed):
137138
if event_data is None:
@@ -161,6 +162,7 @@ async def openai_stream_generator() -> AsyncGenerator[str, None]:
161162
# Handle add_message events
162163
if event_type == "token":
163164
token_data = data.get("chunk", "")
165+
has_token_events = True
164166
await logger.adebug(
165167
"[OpenAIResponses][stream] token: token_data=%s",
166168
token_data,
@@ -195,11 +197,18 @@ async def openai_stream_generator() -> AsyncGenerator[str, None]:
195197
message_state,
196198
)
197199

198-
# Skip processing text content if state is "complete"
199-
# All content has already been streamed via token events
200-
if message_state == "complete":
200+
# Skip processing text content when token events are
201+
# the authoritative source of streamed content. The
202+
# component emits both an add_message and a token
203+
# event for the first chunk, so processing both would
204+
# cause the same content to appear twice in the
205+
# response stream (#10719).
206+
if message_state == "complete" or has_token_events:
201207
await logger.adebug(
202-
"[OpenAIResponses][stream] skipping add_message with state=complete"
208+
"[OpenAIResponses][stream] skipping add_message text "
209+
"(state=%s, has_token_events=%s)",
210+
message_state,
211+
has_token_events,
203212
)
204213
# Still process content_blocks for tool calls, but skip text content
205214
text = ""

src/lfx/src/lfx/base/models/model.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,16 +271,24 @@ async def _get_chat_result(
271271
async def _handle_stream(self, runnable, inputs):
272272
"""Handle streaming responses from the language model.
273273
274+
Creates a Message whose ``text`` is the async iterator returned by the
275+
model's ``astream`` method and passes it to ``send_message``. During
276+
streaming the component emits *token* events (one per chunk) and a
277+
single *add_message* event to initialise the message bubble in the
278+
frontend. The ``add_message`` event intentionally carries **empty**
279+
text so that streamed content is delivered exclusively through token
280+
events — this avoids duplication in API consumers that process both
281+
event types (see #10719).
282+
274283
Args:
275-
runnable: The language model configured for streaming
276-
inputs: The inputs to send to the model
284+
runnable: The language model configured for streaming.
285+
inputs: The inputs to send to the model.
277286
278287
Returns:
279288
tuple: (Message object if connected to chat output, model result)
280289
"""
281290
lf_message = None
282291
if self.is_connected_to_chat_output():
283-
# Add a Message
284292
if hasattr(self, "graph"):
285293
session_id = self.graph.session_id
286294
elif hasattr(self, "_session_id"):

src/lfx/src/lfx/custom/custom_component/component.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1824,9 +1824,13 @@ async def _process_chunk(
18241824
complete_message += chunk
18251825
if self._event_manager:
18261826
if first_chunk:
1827-
# Send the initial message only on the first chunk
1827+
# Send the initial message event to create the message
1828+
# bubble in the frontend. Use empty text so the content
1829+
# is delivered exclusively through token events — sending
1830+
# the same text via both channels causes duplication in
1831+
# consumers like the OpenAI Responses endpoint (#10719).
18281832
msg_copy = message.model_copy()
1829-
msg_copy.text = complete_message
1833+
msg_copy.text = ""
18301834
await self._send_message_event(msg_copy, id_=message_id)
18311835
await asyncio.to_thread(
18321836
self._event_manager.on_token,

0 commit comments

Comments
 (0)