diff --git a/GUI/src/hooks/useStreamingResponse.tsx b/GUI/src/hooks/useStreamingResponse.tsx index fc7204c..6d53a5f 100644 --- a/GUI/src/hooks/useStreamingResponse.tsx +++ b/GUI/src/hooks/useStreamingResponse.tsx @@ -1,147 +1,336 @@ -import { useState, useRef, useCallback, useEffect } from 'react'; -import axios from 'axios'; -import { ChoiceButton } from 'services/inference'; - -const getNotificationNodeUrl = (): string => { - const value = import.meta.env.REACT_APP_NOTIFICATION_NODE_URL; - if (!value) { - throw new Error( - 'Environment variable REACT_APP_NOTIFICATION_NODE_URL is not defined. ' + - 'Please set it to the base URL of the notification service to enable streaming responses.' - ); - } - return value; -}; -const notificationNodeUrl = getNotificationNodeUrl(); -console.log(notificationNodeUrl); - -interface StreamingOptions { - authorId: string; - conversationHistory: Array<{ authorRole: string; message: string; timestamp: string }>; - url: string; -} - -interface UseStreamingResponseReturn { - startStreaming: (message: string, options: StreamingOptions, onToken: (token: string) => void, onComplete: () => void, onError: (error: string) => void, onButtons?: (buttons: ChoiceButton[]) => void) => Promise; - stopStreaming: () => void; - isStreaming: boolean; -} - -export const useStreamingResponse = (channelId: string): UseStreamingResponseReturn => { - const [isStreaming, setIsStreaming] = useState(false); - const eventSourceRef = useRef(null); - - const stopStreaming = useCallback(() => { - if (eventSourceRef.current) { - console.log('[SSE] Closing connection'); - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - setIsStreaming(false); - }, []); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (eventSourceRef.current) { - eventSourceRef.current.close(); - } - }; - }, []); - - const startStreaming = useCallback( - async ( - message: string, - options: StreamingOptions, - onToken: (token: string) => void, - onComplete: () => void, - onError: (error: string) => void, - onButtons?: (buttons: ChoiceButton[]) => void - ) => { - console.log('[SSE] Starting streaming for channel:', channelId); - - // Close any existing connection - stopStreaming(); - - try { - // Step 1: Open SSE connection FIRST - const sseUrl = `${notificationNodeUrl}/sse/stream/${channelId}`; - console.log('[SSE] Connecting to:', sseUrl); - - const eventSource = new EventSource(sseUrl); - eventSourceRef.current = eventSource; - - eventSource.onopen = () => { - console.log('[SSE] Connection opened'); - }; - - eventSource.onmessage = (event) => { - console.log('[SSE] Message received:', event.data); - - try { - const data = JSON.parse(event.data); - - if (data.type === 'stream_start') { - console.log('[SSE] Stream started'); - setIsStreaming(true); - } else if (data.type === 'stream_chunk' && data.content) { - console.log('[SSE] Token:', data.content); - onToken(data.content); - if (data.buttons && data.buttons.length > 0 && onButtons) { - onButtons(data.buttons); - } - } else if (data.type === 'stream_end') { - console.log('[SSE] Stream ended'); - setIsStreaming(false); - eventSource.close(); - eventSourceRef.current = null; - onComplete(); - } else if (data.type === 'stream_error') { - console.error('[SSE] Stream error:', data.error); - setIsStreaming(false); - eventSource.close(); - eventSourceRef.current = null; - onError(data.error || 'Stream error occurred'); - } - } catch (e) { - console.error('[SSE] Failed to parse message:', e); - } - }; - - eventSource.onerror = (err) => { - console.error('[SSE] Connection error:', err); - setIsStreaming(false); - eventSource.close(); - eventSourceRef.current = null; - onError('Connection error'); - }; - - // Step 2: Wait a moment for SSE connection to establish, then trigger the stream - await new Promise(resolve => setTimeout(resolve, 500)); - - // Step 3: POST to trigger streaming - const postUrl = `${notificationNodeUrl}/channels/${channelId}/orchestrate/stream`; - console.log('[API] Triggering stream:', postUrl); - - await axios.post(postUrl, { - message, - options, - }); - - console.log('[API] Stream triggered successfully'); - - } catch (err) { - console.error('[SSE] Error starting stream:', err); - stopStreaming(); - onError(err instanceof Error ? err.message : 'Failed to start streaming'); - } - }, - [channelId, stopStreaming] - ); - - return { - startStreaming, - stopStreaming, - isStreaming, - }; -}; \ No newline at end of file +import { useState, useRef, useCallback, useEffect } from 'react'; +import axios from 'axios'; +import { ChoiceButton } from 'services/inference'; + +const getNotificationNodeUrl = (): string => { + const value = import.meta.env.REACT_APP_NOTIFICATION_NODE_URL; + if (!value) { + throw new Error( + 'Environment variable REACT_APP_NOTIFICATION_NODE_URL is not defined. ' + + 'Please set it to the base URL of the notification service to enable streaming responses.' + ); + } + return value; +}; +const notificationNodeUrl = getNotificationNodeUrl(); +console.log(notificationNodeUrl); + +// The trigger POST is held open by the notification server for the full +// generation, so it needs a ceiling well above any realistic answer time. +const TRIGGER_POST_TIMEOUT_MS = 600_000; + +// How long to keep waiting on SSE after the trigger POST has failed. A failed +// POST is not proof that generation failed - it may already be streaming - but +// if nothing has arrived by now, nothing is coming: the POST failed before the +// server ever dispatched to the relay, so no stream_end or stream_error will +// ever be sent and without this the UI would wait forever. +const TRIGGER_FAILURE_GRACE_MS = 15_000; + +// Messages that prove the backend actually engaged this stream. Heartbeats are +// SSE comment frames, which EventSource never surfaces, so they cannot count. +const STREAM_EVENT_TYPES = new Set([ + 'stream_start', + 'stream_chunk', + 'stream_end', + 'stream_error', +]); + +// Typewriter pacing. +// +// Output guardrails validate the answer in blocks before releasing it, so tokens +// reach the browser in bursts (roughly 200, then 150, then the tail) rather than +// one at a time. Rendering each burst the instant it lands makes the answer snap +// onto the screen. Instead we queue arriving tokens and drain them on a timer, +// which gives a steady word-by-word effect without weakening the guardrails or +// paying for the extra validation calls that smaller server-side chunks cost. +// +// Typing speed. This is the knob to turn if the effect feels too fast or slow - +// higher is faster. Around 25/s reads like brisk typing; 40+/s starts to look +// like the text is simply appearing. +const TYPING_TOKENS_PER_SECOND = 25; +// Ceiling on how far rendering may fall behind the stream. If a burst arrives +// faster than the typing speed, the drain rate rises so the backlog still clears +// within this budget rather than typing on long after the answer is complete. +// Approximate: setInterval fires late under load, so expect ~15-20% over. +const MAX_CATCH_UP_SECONDS = 8; + +const DRAIN_INTERVAL_MS = Math.round(1000 / TYPING_TOKENS_PER_SECOND); +const DRAIN_TARGET_TICKS = Math.round( + (MAX_CATCH_UP_SECONDS * 1000) / DRAIN_INTERVAL_MS +); + +interface StreamingOptions { + authorId: string; + conversationHistory: Array<{ authorRole: string; message: string; timestamp: string }>; + url: string; +} + +interface UseStreamingResponseReturn { + startStreaming: (message: string, options: StreamingOptions, onToken: (token: string) => void, onComplete: () => void, onError: (error: string) => void, onButtons?: (buttons: ChoiceButton[]) => void) => Promise; + stopStreaming: () => void; + isStreaming: boolean; +} + +export const useStreamingResponse = (channelId: string): UseStreamingResponseReturn => { + const [isStreaming, setIsStreaming] = useState(false); + const eventSourceRef = useRef(null); + + // Typewriter state + const queueRef = useRef([]); + const drainTimerRef = useRef | null>(null); + // Tokens emitted per tick. Only ever raised during a run: recomputing it from + // the shrinking queue each tick would decay the rate geometrically and stretch + // a large backlog out well beyond MAX_CATCH_UP_SECONDS. + const drainRateRef = useRef(1); + const streamEndedRef = useRef(false); + const pendingButtonsRef = useRef(null); + // Set by any stream event; read by the trigger-POST fallback below. + const sawStreamEventRef = useRef(false); + const triggerFallbackTimerRef = useRef | null>(null); + const onTokenRef = useRef<(token: string) => void>(() => {}); + const onCompleteRef = useRef<() => void>(() => {}); + const onButtonsRef = useRef<((buttons: ChoiceButton[]) => void) | undefined>(undefined); + + const stopDrain = useCallback(() => { + if (drainTimerRef.current) { + clearInterval(drainTimerRef.current); + drainTimerRef.current = null; + } + }, []); + + const clearTriggerFallback = useCallback(() => { + if (triggerFallbackTimerRef.current) { + clearTimeout(triggerFallbackTimerRef.current); + triggerFallbackTimerRef.current = null; + } + }, []); + + // Drop anything not yet rendered. Used when a guardrail blocks the answer or + // the user cancels: text the rail rejected must never reach the screen. + const discardQueue = useCallback(() => { + queueRef.current = []; + pendingButtonsRef.current = null; + drainRateRef.current = 1; + stopDrain(); + }, [stopDrain]); + + const startDrain = useCallback(() => { + if (drainTimerRef.current) return; + + drainTimerRef.current = setInterval(() => { + const queue = queueRef.current; + + if (queue.length === 0) { + if (streamEndedRef.current) { + stopDrain(); + if (pendingButtonsRef.current?.length && onButtonsRef.current) { + onButtonsRef.current(pendingButtonsRef.current); + pendingButtonsRef.current = null; + } + setIsStreaming(false); + onCompleteRef.current(); + } + return; + } + + drainRateRef.current = Math.max( + drainRateRef.current, + Math.ceil(queue.length / DRAIN_TARGET_TICKS) + ); + onTokenRef.current(queue.splice(0, drainRateRef.current).join('')); + }, DRAIN_INTERVAL_MS); + }, [stopDrain]); + + const stopStreaming = useCallback(() => { + if (eventSourceRef.current) { + console.log('[SSE] Closing connection'); + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + clearTriggerFallback(); + discardQueue(); + setIsStreaming(false); + }, [discardQueue, clearTriggerFallback]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + if (drainTimerRef.current) { + clearInterval(drainTimerRef.current); + } + if (triggerFallbackTimerRef.current) { + clearTimeout(triggerFallbackTimerRef.current); + } + }; + }, []); + + const startStreaming = useCallback( + async ( + message: string, + options: StreamingOptions, + onToken: (token: string) => void, + onComplete: () => void, + onError: (error: string) => void, + onButtons?: (buttons: ChoiceButton[]) => void + ) => { + console.log('[SSE] Starting streaming for channel:', channelId); + + // Close any existing connection + stopStreaming(); + + // Reset typewriter state for this run + queueRef.current = []; + drainRateRef.current = 1; + streamEndedRef.current = false; + pendingButtonsRef.current = null; + sawStreamEventRef.current = false; + onTokenRef.current = onToken; + onCompleteRef.current = onComplete; + onButtonsRef.current = onButtons; + + try { + // Step 1: Open SSE connection FIRST + const sseUrl = `${notificationNodeUrl}/sse/stream/${channelId}`; + console.log('[SSE] Connecting to:', sseUrl); + + const eventSource = new EventSource(sseUrl); + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + console.log('[SSE] Connection opened'); + }; + + eventSource.onmessage = (event) => { + console.log('[SSE] Message received:', event.data); + + try { + const data = JSON.parse(event.data); + + if (STREAM_EVENT_TYPES.has(data.type)) { + // The backend is talking to us, so the trigger-POST fallback must + // not fire - whatever happens next arrives over this connection. + sawStreamEventRef.current = true; + clearTriggerFallback(); + } + + if (data.type === 'stream_start') { + console.log('[SSE] Stream started'); + setIsStreaming(true); + startDrain(); + } else if (data.type === 'stream_chunk' && data.content) { + // Queue rather than render, so bursts play out word by word. + queueRef.current.push(data.content); + if (data.buttons && data.buttons.length > 0) { + // Held back until the text finishes typing, so choices do not + // appear above a half-rendered answer. + pendingButtonsRef.current = data.buttons; + } + startDrain(); + } else if (data.type === 'stream_end') { + console.log('[SSE] Stream ended'); + eventSource.close(); + eventSourceRef.current = null; + // Do not fire onComplete yet - let the queue finish rendering. + streamEndedRef.current = true; + startDrain(); + } else if (data.type === 'stream_error') { + console.error('[SSE] Stream error:', data.error); + // Discard unrendered text: a guardrail may have just rejected it. + discardQueue(); + setIsStreaming(false); + eventSource.close(); + eventSourceRef.current = null; + onError(data.error || 'Stream error occurred'); + } + } catch (e) { + console.error('[SSE] Failed to parse message:', e); + } + }; + + eventSource.onerror = (err) => { + console.error('[SSE] Connection error:', err); + // Otherwise a pending fallback would later report a second error for + // the same failed run. + clearTriggerFallback(); + discardQueue(); + setIsStreaming(false); + eventSource.close(); + eventSourceRef.current = null; + onError('Connection error'); + }; + + // Step 2: Wait a moment for SSE connection to establish, then trigger the stream + await new Promise(resolve => setTimeout(resolve, 500)); + + // Step 3: POST to trigger streaming. + // Note: this request stays open for the whole generation, so it can fail + // (gateway 504, network blip) while the SSE stream is perfectly healthy. + const postUrl = `${notificationNodeUrl}/channels/${channelId}/orchestrate/stream`; + console.log('[API] Triggering stream:', postUrl); + + try { + await axios.post( + postUrl, + { message, options }, + { timeout: TRIGGER_POST_TIMEOUT_MS } + ); + console.log('[API] Stream triggered successfully'); + } catch (postErr) { + // Do NOT tear down the EventSource here. The answer arrives over SSE, + // not in this response body; killing the stream on a POST failure is + // what turned a slow answer into a truncated one. Let stream_end / + // stream_error decide, and only surface an error if neither arrives. + console.warn( + '[API] Trigger POST failed; keeping SSE open and waiting for stream events:', + postErr + ); + + const postErrMessage = + postErr instanceof Error ? postErr.message : 'Failed to start streaming'; + + if (!eventSourceRef.current) { + // SSE already gone - nothing left to wait for. + onError(postErrMessage); + return; + } + + // Bound the wait. If the POST failed before the server dispatched to + // the relay (a 4xx, or no active connection so the request was only + // queued), no stream event will ever arrive and there is nothing to + // end the stream - so give up once the grace period expires. + clearTriggerFallback(); + triggerFallbackTimerRef.current = setTimeout(() => { + triggerFallbackTimerRef.current = null; + if (sawStreamEventRef.current) return; + + console.error( + `[SSE] No stream events ${TRIGGER_FAILURE_GRACE_MS}ms after trigger POST failed; giving up` + ); + discardQueue(); + setIsStreaming(false); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + onError(postErrMessage); + }, TRIGGER_FAILURE_GRACE_MS); + } + + } catch (err) { + console.error('[SSE] Error starting stream:', err); + stopStreaming(); + onError(err instanceof Error ? err.message : 'Failed to start streaming'); + } + }, + [channelId, stopStreaming, startDrain, discardQueue, clearTriggerFallback] + ); + + return { + startStreaming, + stopStreaming, + isStreaming, + }; +}; diff --git a/notification-server/src/connectionManager.js b/notification-server/src/connectionManager.js index a2dee15..5dba38b 100644 --- a/notification-server/src/connectionManager.js +++ b/notification-server/src/connectionManager.js @@ -1,5 +1,52 @@ const activeConnections = new Map(); +/** + * Register an AbortController for an in-flight upstream request belonging to a + * connection, so it can be cancelled when the browser disconnects. + * @param {string} connectionId + * @param {AbortController} controller + */ +function registerAbortController(connectionId, controller) { + const connData = activeConnections.get(connectionId); + if (!connData) return; + if (!connData.abortControllers) { + connData.abortControllers = new Set(); + } + connData.abortControllers.add(controller); +} + +/** + * Remove a previously registered AbortController (upstream request finished). + * @param {string} connectionId + * @param {AbortController} controller + */ +function unregisterAbortController(connectionId, controller) { + const connData = activeConnections.get(connectionId); + connData?.abortControllers?.delete(controller); +} + +/** + * Abort every in-flight upstream request for a connection. Called when the + * browser goes away, so we stop paying for generation nobody will read. + * @param {string} connectionId + */ +function abortConnectionRequests(connectionId) { + const connData = activeConnections.get(connectionId); + if (!connData?.abortControllers) return; + + for (const controller of connData.abortControllers) { + try { + controller.abort(); + } catch (error) { + console.error(`Failed to abort upstream request for ${connectionId}:`, error); + } + } + connData.abortControllers.clear(); +} + module.exports = { activeConnections, + registerAbortController, + unregisterAbortController, + abortConnectionRequests, }; diff --git a/notification-server/src/server.js b/notification-server/src/server.js index 98e9157..d2f67ad 100644 --- a/notification-server/src/server.js +++ b/notification-server/src/server.js @@ -82,4 +82,12 @@ const server = app.listen(serverConfig.port, () => { console.log(`LLM orchestration streaming at: /channels/:channelId/orchestrate/stream`); }); +// The SSE GET and the trigger POST are both long-lived by design, so Node's +// default 300s requestTimeout would cut them off mid-answer. Disable the +// per-request cap and let the upstream idle watchdog in streamingService.js +// decide when a stream has genuinely stalled. +server.requestTimeout = 0; +server.headersTimeout = 65_000; +server.keepAliveTimeout = 61_000; + module.exports = server; diff --git a/notification-server/src/sseUtil.js b/notification-server/src/sseUtil.js index a2ad0c1..bd91501 100644 --- a/notification-server/src/sseUtil.js +++ b/notification-server/src/sseUtil.js @@ -1,11 +1,18 @@ const { v4: uuidv4 } = require('uuid'); const streamQueue = require("./streamQueue"); const { createLLMOrchestrationStreamRequest } = require("./streamingService"); -const { activeConnections } = require("./connectionManager"); +const { activeConnections, abortConnectionRequests } = require("./connectionManager"); + +// Comment frames are written this often so that every intermediate proxy sees +// traffic and does not close the connection on its idle timer. EventSource +// ignores comment frames, so this is invisible to the browser. +const HEARTBEAT_INTERVAL_MS = Number( + process.env.SSE_HEARTBEAT_INTERVAL_MS || 15_000 +); function buildSSEResponse({ res, req, buildCallbackFunction, channelId }) { addSSEHeader(req, res); - keepStreamAlive(res); + const heartbeat = keepStreamAlive(res); const connectionId = generateConnectionID(); const sender = buildSender(res); @@ -13,6 +20,7 @@ function buildSSEResponse({ res, req, buildCallbackFunction, channelId }) { res, sender, channelId, + abortControllers: new Set(), }); if (channelId) { @@ -25,6 +33,9 @@ function buildSSEResponse({ res, req, buildCallbackFunction, channelId }) { req.on("close", () => { console.log(`Client disconnected from SSE for channel ${channelId}`); + clearInterval(heartbeat); + // Cancel any in-flight upstream generation - nobody is left to read it. + abortConnectionRequests(connectionId); activeConnections.delete(connectionId); cleanUp?.(); }); @@ -37,6 +48,8 @@ function addSSEHeader(req, res) { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', + // Stops nginx-style proxies buffering the stream into a single response. + 'X-Accel-Buffering': 'no', 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Credentials': true, 'Access-Control-Expose-Headers': 'Origin, X-Requested-With, Content-Type, Cache-Control, Connection, Accept' @@ -49,8 +62,33 @@ function extractOrigin(reqOrigin) { return whitelisted ? reqOrigin : '*'; } +/** + * Keep the SSE connection warm with periodic comment frames. + * + * Previously a single `res.write('')`, which did nothing beyond the initial + * flush - any proxy between the browser and this server would still time the + * connection out during a long generation pause. + * + * @returns {NodeJS.Timeout} interval handle; the caller must clear it on close. + */ function keepStreamAlive(res) { res.write(''); + const heartbeat = setInterval(() => { + try { + // A `:` line is an SSE comment: ignored by EventSource, but it is traffic. + res.write(': ping\n\n'); + if (typeof res.flush === "function") { + res.flush(); + } + } catch (error) { + console.error("SSE heartbeat write failed:", error); + clearInterval(heartbeat); + } + }, HEARTBEAT_INTERVAL_MS); + + // Do not hold the event loop open purely for a heartbeat. + heartbeat.unref?.(); + return heartbeat; } function generateConnectionID() { diff --git a/notification-server/src/streamingService.js b/notification-server/src/streamingService.js index aaf1279..cdb1df4 100644 --- a/notification-server/src/streamingService.js +++ b/notification-server/src/streamingService.js @@ -1,6 +1,96 @@ -const { activeConnections } = require("./connectionManager"); +const { + activeConnections, + registerAbortController, + unregisterAbortController, +} = require("./connectionManager"); const streamQueue = require("./streamQueue"); +// Inactivity budget for the upstream SSE body. This is an idle timeout, not a +// total-duration cap: the timer resets on every byte received, so a long answer +// streams fine while a genuinely stalled upstream fails fast with a clear error. +// Kept below undici's 300s default bodyTimeout so we control the failure mode +// (undici's would surface as an opaque `TypeError: terminated`). +const UPSTREAM_IDLE_TIMEOUT_MS = Number( + process.env.LLM_STREAM_IDLE_TIMEOUT_MS || 120_000 +); + +const ORCHESTRATOR_URL = + process.env.LLM_ORCHESTRATOR_URL || "http://llm-orchestration-service:8100"; + +/** + * Translate one SSE `data:` line into a message for the browser. + * @returns {boolean} true if this line ended the stream + */ +function relaySSELine({ line, channelId, sender }) { + if (!line.trim()) return false; + if (!line.startsWith("data: ")) return false; // ignores `: ping` heartbeats + + try { + const data = JSON.parse(line.slice(6)); // Remove 'data: ' prefix + const content = data.payload?.content; + const buttons = data.payload?.buttons; + + if (!content) return false; + + if (content === "END") { + sender({ + type: "stream_end", + streamId: channelId, + channelId, + isComplete: true, + }); + return true; + } + + // Regular token - send to client (include buttons when present) + const chunkMessage = { + type: "stream_chunk", + content: content, + streamId: channelId, + channelId, + isComplete: false, + }; + if (buttons && buttons.length > 0) { + chunkMessage.buttons = buttons; + } + sender(chunkMessage); + return false; + } catch (parseError) { + console.error(`Failed to parse SSE data for channel ${channelId}:`, parseError, line); + return false; + } +} + +/** + * Drain the upstream SSE body, relaying each frame to the browser. + * Returns once the stream ends, the client disconnects, or END is received. + * @returns {Promise} true if an END frame terminated the stream + */ +async function relayUpstreamBody({ response, connectionId, channelId, sender, onActivity }) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (activeConnections.has(connectionId)) { + const { done, value } = await reader.read(); + if (done) break; + + onActivity(); + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // Keep the incomplete line in buffer + + for (const line of lines) { + // Returning here (rather than `break`) also closes the upstream body — + // a bare `break` only left the inner loop and kept the connection open. + if (relaySSELine({ line, channelId, sender })) return true; + } + } + + return false; +} + /** * Stream LLM orchestration response to connected clients * @param {Object} params - Request parameters @@ -17,7 +107,7 @@ async function createLLMOrchestrationStreamRequest({ channelId, message, options if (connections.length === 0) { streamQueue.addToQueue(channelId, { message, options }); - + if (streamQueue.shouldRetry({ retryCount: 0 })) { throw new Error("No active connections found for this channel - request queued"); } else { @@ -28,116 +118,9 @@ async function createLLMOrchestrationStreamRequest({ channelId, message, options console.log(`Streaming LLM orchestration for channel ${channelId} to ${connections.length} connections`); try { - const responsePromises = connections.map(async ([connectionId, connData]) => { - const { sender } = connData; - - try { - // Construct OrchestrationRequest payload - const orchestrationPayload = { - chatId: channelId, - message: message, - authorId: options.authorId || `user-${channelId}`, - conversationHistory: options.conversationHistory || [], - url: options.url || "sse-stream-context", - environment: options.environment || "production", - connection_id: options.connection_id - }; - - console.log(`Calling LLM orchestration stream for channel ${channelId}`); - - // Call the LLM orchestration streaming endpoint - const response = await fetch(`${process.env.LLM_ORCHESTRATOR_URL || 'http://llm-orchestration-service:8100'}/orchestrate/stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(orchestrationPayload), - }); - - if (!response.ok) { - throw new Error(`LLM Orchestration API error: ${response.status} ${response.statusText}`); - } - - if (!activeConnections.has(connectionId)) { - return; - } - - // Send stream start notification - sender({ - type: "stream_start", - streamId: channelId, - channelId, - isComplete:false - }); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - if (!activeConnections.has(connectionId)) break; - - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // Keep the incomplete line in buffer - - for (const line of lines) { - if (!line.trim()) continue; - if (!line.startsWith('data: ')) continue; - - try { - const data = JSON.parse(line.slice(6)); // Remove 'data: ' prefix - const content = data.payload?.content; - const buttons = data.payload?.buttons; - - if (!content) continue; - - if (content === "END") { - // Stream completed - sender({ - type: "stream_end", - streamId: channelId, - channelId, - isComplete:true - }); - break; - } - - // Regular token - send to client (include buttons when present) - const chunkMessage = { - type: "stream_chunk", - content: content, - streamId: channelId, - channelId, - isComplete:false - }; - if (buttons && buttons.length > 0) { - chunkMessage.buttons = buttons; - } - sender(chunkMessage); - - } catch (parseError) { - console.error(`Failed to parse SSE data for channel ${channelId}:`, parseError, line); - } - } - } - - } catch (error) { - console.error(`Streaming error for connection ${connectionId}:`, error); - if (activeConnections.has(connectionId)) { - sender({ - type: "stream_error", - error: error.message, - streamId: channelId, - channelId, - isComplete:true - }); - } - } - }); + const responsePromises = connections.map(([connectionId, connData]) => + streamToConnection({ connectionId, connData, channelId, message, options }) + ); await Promise.all(responsePromises); return { success: true, message: "Stream completed" }; @@ -148,6 +131,138 @@ async function createLLMOrchestrationStreamRequest({ channelId, message, options } } +/** + * Run one upstream orchestration stream and relay it to a single SSE connection. + */ +async function streamToConnection({ connectionId, connData, channelId, message, options }) { + const { sender } = connData; + const abortController = new AbortController(); + let idleTimer = null; + let idleTimedOut = false; + + // Idle watchdog: reset on every chunk received. Only fires when the upstream + // genuinely stops producing, never on a merely long answer. + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + idleTimedOut = true; + console.error( + `Upstream idle for ${UPSTREAM_IDLE_TIMEOUT_MS}ms on channel ${channelId} - aborting` + ); + abortController.abort(); + }, UPSTREAM_IDLE_TIMEOUT_MS); + }; + + try { + // Construct OrchestrationRequest payload + const orchestrationPayload = { + chatId: channelId, + message: message, + authorId: options.authorId || `user-${channelId}`, + conversationHistory: options.conversationHistory || [], + url: options.url || "sse-stream-context", + environment: options.environment || "production", + connection_id: options.connection_id + }; + + console.log(`Calling LLM orchestration stream for channel ${channelId}`); + + // The controller serves two purposes: cancelling the upstream request when + // the browser disconnects, and enforcing the idle timeout above. + registerAbortController(connectionId, abortController); + resetIdleTimer(); + + // Call the LLM orchestration streaming endpoint + const response = await fetch(`${ORCHESTRATOR_URL}/orchestrate/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(orchestrationPayload), + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error(`LLM Orchestration API error: ${response.status} ${response.statusText}`); + } + + if (!activeConnections.has(connectionId)) { + return; + } + + // Send stream start notification + sender({ + type: "stream_start", + streamId: channelId, + channelId, + isComplete: false + }); + + const sawEnd = await relayUpstreamBody({ + response, + connectionId, + channelId, + sender, + onActivity: resetIdleTimer, + }); + + // The body closed without an END frame. The browser has a stream_start and + // possibly some chunks, but nothing that ends the stream, so it would spin + // forever. Terminate it explicitly rather than trusting every upstream error + // path to remember the marker. A client that has already disconnected needs + // no notification. + if (!sawEnd && activeConnections.has(connectionId)) { + console.error( + `Upstream body ended without END marker on channel ${channelId} ` + + `(connection ${connectionId})` + ); + sender({ + type: "stream_error", + error: "The response ended unexpectedly. Please try again.", + streamId: channelId, + channelId, + isComplete: true, + }); + } + + } catch (error) { + // A client-disconnect abort is expected teardown, not a failure: the browser + // is gone, there is nobody to notify and nothing to log loudly. + if (error.name === "AbortError" && !idleTimedOut) { + console.log( + `Upstream stream cancelled for connection ${connectionId} (client disconnected)` + ); + return; + } + + if (idleTimedOut) { + // The AbortError here is our own watchdog firing; its stack says nothing + // useful, so report the actual cause instead. + console.error( + `Streaming timed out for connection ${connectionId}: no upstream output ` + + `for ${UPSTREAM_IDLE_TIMEOUT_MS}ms on channel ${channelId}` + ); + } else { + console.error(`Streaming error for connection ${connectionId}:`, error); + } + + if (activeConnections.has(connectionId)) { + sender({ + type: "stream_error", + error: idleTimedOut + ? "The response timed out. Please try again." + : error.message, + streamId: channelId, + channelId, + isComplete: true + }); + } + } finally { + if (idleTimer) clearTimeout(idleTimer); + unregisterAbortController(connectionId, abortController); + } +} + module.exports = { createLLMOrchestrationStreamRequest, }; diff --git a/src/guardrails/nemo_rails_adapter.py b/src/guardrails/nemo_rails_adapter.py index fa8e98f..5374914 100644 --- a/src/guardrails/nemo_rails_adapter.py +++ b/src/guardrails/nemo_rails_adapter.py @@ -130,6 +130,8 @@ def _ensure_initialized(self) -> None: rails_config.streaming = True + self._validate_output_streaming_config(rails_config) + if metadata.get("optimized", False): version = metadata.get("version", "unknown") metrics = metadata.get("metrics", {}) @@ -169,6 +171,44 @@ def _ensure_initialized(self) -> None: logger.exception("Full traceback:") raise + @staticmethod + def _validate_output_streaming_config(rails_config: RailsConfig) -> None: + """ + Guard against a buffer configuration that degenerates into one guardrail + LLM call per streamed token. + + NeMo's RollingBuffer drains with ``buffer = buffer[-context_size:]`` after + each flush. If ``context_size >= chunk_size`` the buffer never shrinks below + the ``len(buffer) >= chunk_size`` flush threshold, so every subsequent token + flushes on its own and triggers a full, serial ``self_check_output`` LLM + round-trip. That makes streaming latency and cost scale linearly with answer + length and is invisible without this check. + """ + streaming_config = getattr( + getattr(getattr(rails_config, "rails", None), "output", None), + "streaming", + None, + ) + if streaming_config is None or not getattr(streaming_config, "enabled", False): + return + + chunk_size = getattr(streaming_config, "chunk_size", 0) + context_size = getattr(streaming_config, "context_size", 0) + + if chunk_size > 0 and context_size >= chunk_size: + clamped = max(1, chunk_size // 4) + logger.error( + f"Invalid output-rails streaming config: context_size={context_size} " + f">= chunk_size={chunk_size}. This degrades to one guardrail LLM call " + f"per streamed token. Clamping context_size to {clamped}." + ) + streaming_config.context_size = clamped + else: + logger.debug( + f"Output-rails streaming buffer OK: chunk_size={chunk_size}, " + f"context_size={context_size}" + ) + async def check_input_async(self, user_message: str) -> GuardrailCheckResult: """ Check user input against guardrails (async version for streaming). @@ -383,6 +423,9 @@ async def stream_with_guardrails( logger.debug(f"Generator type: {type(bot_message_generator)}") chunk_count = 0 + stream_started_at = asyncio.get_running_loop().time() + last_chunk_at = stream_started_at + max_gap_seconds = 0.0 logger.info("Calling _rails.stream_async with generator parameter...") @@ -392,16 +435,33 @@ async def stream_with_guardrails( ): chunk_count += 1 - if chunk_count <= 10: + now = asyncio.get_running_loop().time() + gap = now - last_chunk_at + last_chunk_at = now + max_gap_seconds = max(max_gap_seconds, gap) + + # Log the head of the stream, then periodically. Logging only the + # first N chunks hides per-token guardrail stalls, which look like + # a total outage in the log while tokens are actually trickling. + if chunk_count <= 10 or chunk_count % 50 == 0: logger.debug( - f"[Chunk {chunk_count}] Validated and yielded: {repr(chunk)}" + f"[Chunk {chunk_count}] Validated and yielded: {repr(chunk)} " + f"(gap={gap:.2f}s, elapsed={now - stream_started_at:.2f}s)" ) yield chunk + total_elapsed = asyncio.get_running_loop().time() - stream_started_at logger.info( - f"NeMo streaming completed successfully - {chunk_count} chunks streamed" + f"NeMo streaming completed successfully - {chunk_count} chunks streamed " + f"in {total_elapsed:.2f}s (max inter-chunk gap {max_gap_seconds:.2f}s)" ) + if max_gap_seconds > 10.0: + logger.warning( + f"Output-rails streaming stalled for {max_gap_seconds:.2f}s between " + f"chunks. Check rails.output.streaming (context_size must be < " + f"chunk_size) — a per-token guardrail call causes this." + ) except Exception as e: logger.error(f"Error in stream_with_guardrails: {str(e)}") diff --git a/src/guardrails/rails_config.yaml b/src/guardrails/rails_config.yaml index 42116e9..a52ffd2 100644 --- a/src/guardrails/rails_config.yaml +++ b/src/guardrails/rails_config.yaml @@ -23,7 +23,11 @@ rails: streaming: enabled: True chunk_size: 200 - context_size: 300 + # context_size MUST be < chunk_size. NeMo's RollingBuffer drains with + # buffer[-context_size:]; if context_size >= chunk_size the buffer never + # shrinks below the flush threshold and every subsequent token triggers its + # own self_check_output LLM call. 50 is the NeMo default. + context_size: 50 stream_first: False prompts: diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index f20fc03..40b4dd0 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -46,7 +46,11 @@ from src.vector_indexer.constants import ResponseGenerationConstants from src.utils.error_utils import generate_error_id, log_error_with_context from src.utils.stream_manager import stream_manager, StreamContext -from src.utils.cost_utils import calculate_total_costs, get_lm_usage_since +from src.utils.cost_utils import ( + calculate_total_costs, + get_lm_usage_since, + get_lm_usage_since_split, +) if TYPE_CHECKING: from src.llm_orchestrator_config.embedding_manager import EmbeddingManager @@ -1193,9 +1197,15 @@ async def bot_response_generator() -> AsyncIterator[str]: yield self.format_sse(request.chatId, "END") - # Extract usage information after streaming completes - usage_info = get_lm_usage_since(history_length_before) + # Extract usage after streaming completes. Output-rail validation runs + # interleaved with generation in the same history window, so split the + # two - folding them together hid a 100x guardrail cost regression. + usage_info, guardrails_usage = get_lm_usage_since_split( + history_length_before + ) costs_metric["streaming_generation"] = usage_info + if guardrails_usage.get("num_calls", 0) > 0: + costs_metric["output_guardrails"] = guardrails_usage # Record timings time_metric["streaming_generation"] = time.time() - streaming_step_start diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index d5a9b7b..465905b 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -43,7 +43,7 @@ # Both spellings resolve to separate module objects at runtime, so the class # imported here must match the one the config loader raises or `except` misses. from llm_orchestrator_config.exceptions import ConfigurationError -from src.utils.stream_timeout import stream_timeout +from src.utils.stream_timeout import stream_timeout, with_heartbeat from src.utils.observation_utils import safe_observation_context from src.utils.error_utils import generate_error_id, log_error_with_context from src.utils.rate_limiter import RateLimiter @@ -554,16 +554,27 @@ async def stream_orchestrated_response( from datetime import datetime def create_sse_error_stream(chat_id: str, error_message: str) -> str: - """Create SSE format error response.""" + """Create an SSE error response, terminated by the END marker. + + The END frame is what the notification server translates into the + browser's ``stream_end``. Without it an error frame is indistinguishable + from ordinary content, so the client keeps waiting on a stream that has + already finished - which is how an upstream timeout turned into a chat + that hung indefinitely. Every caller is a terminal error path, so + closing the stream here is always correct. + """ from typing import Dict, Any - error_payload: Dict[str, Any] = { - "chatId": chat_id, - "payload": {"content": error_message}, - "timestamp": str(int(datetime.now().timestamp() * 1000)), - "sentTo": [], - } - return f"data: {json_module.dumps(error_payload)}\n\n" + def frame(content: str) -> str: + payload: Dict[str, Any] = { + "chatId": chat_id, + "payload": {"content": content}, + "timestamp": str(int(datetime.now().timestamp() * 1000)), + "sentTo": [], + } + return f"data: {json_module.dumps(payload)}\n\n" + + return frame(error_message) + frame("END") try: logger.info( @@ -685,10 +696,15 @@ async def timeout_wrapped_stream() -> AsyncGenerator[str, None]: ): try: async with stream_timeout(StreamConfig.MAX_STREAM_DURATION_SECONDS): - async for ( - chunk - ) in orchestration_service.stream_orchestration_response( - request + # Heartbeat frames keep proxies from closing a slow stream, + # and the idle budget fails fast on one that has truly + # stalled rather than waiting out the total-duration cap. + async for chunk in with_heartbeat( + orchestration_service.stream_orchestration_response( + request + ), + heartbeat_interval=StreamConfig.HEARTBEAT_INTERVAL_SECONDS, + idle_timeout=StreamConfig.IDLE_TIMEOUT_SECONDS, ): yield chunk except StreamTimeoutError as timeout_exc: diff --git a/src/llm_orchestrator_config/stream_config.py b/src/llm_orchestrator_config/stream_config.py index 84e5edd..39b433d 100644 --- a/src/llm_orchestrator_config/stream_config.py +++ b/src/llm_orchestrator_config/stream_config.py @@ -5,8 +5,13 @@ class StreamConfig: """Hardcoded configuration for streaming limits and timeouts.""" # Timeout Configuration - MAX_STREAM_DURATION_SECONDS: int = 300 # 5 minutes - IDLE_TIMEOUT_SECONDS: int = 60 # 1 minute idle timeout + MAX_STREAM_DURATION_SECONDS: int = 300 # 5 minutes, total wall clock + # Measured between chunks, not cumulatively: a long answer that keeps + # producing tokens is never cut off, however long it takes in total. + IDLE_TIMEOUT_SECONDS: int = 60 # 1 minute with no output at all + # How often to emit an SSE comment frame while the stream is quiet, so + # intermediate proxies see traffic and hold the connection open. + HEARTBEAT_INTERVAL_SECONDS: int = 15 # Size Limits MAX_MESSAGE_LENGTH: int = 10000 # Maximum characters in message diff --git a/src/optimization/optimization_scripts/extract_guardrails_prompts.py b/src/optimization/optimization_scripts/extract_guardrails_prompts.py index 501452a..9522d44 100644 --- a/src/optimization/optimization_scripts/extract_guardrails_prompts.py +++ b/src/optimization/optimization_scripts/extract_guardrails_prompts.py @@ -17,6 +17,20 @@ FULL_TRACEBACK_MSG = "Full traceback:" FEW_SHOT_EXAMPLES_HEADER = "\nFew-shot Examples (from optimization):" +# Output-rails streaming buffer, written into every generated config. +# +# OUTPUT_STREAMING_CONTEXT_SIZE MUST stay below OUTPUT_STREAMING_CHUNK_SIZE. +# NeMo's RollingBuffer drains with ``buffer = buffer[-context_size:]`` after each +# flush; if context_size >= chunk_size the buffer never falls back below the +# flush threshold, so every subsequent token flushes on its own and triggers a +# full self_check_output LLM round-trip. That makes streaming latency and cost +# scale linearly with answer length. +# +# Keep in sync with rails.output.streaming in src/guardrails/rails_config.yaml. +OUTPUT_STREAMING_CHUNK_SIZE = 200 +OUTPUT_STREAMING_CONTEXT_SIZE = 50 +OUTPUT_STREAMING_STREAM_FIRST = False + # Type aliases for better readability JsonDict = Dict[str, Any] PromptDict = Dict[str, Any] @@ -362,9 +376,17 @@ def _ensure_required_config_structure(base_config: Dict[str, Any]) -> None: # Set required streaming parameters (override existing values to ensure consistency) output_streaming["enabled"] = True - output_streaming["chunk_size"] = 200 - output_streaming["context_size"] = 300 - output_streaming["stream_first"] = False + output_streaming["chunk_size"] = OUTPUT_STREAMING_CHUNK_SIZE + output_streaming["context_size"] = OUTPUT_STREAMING_CONTEXT_SIZE + output_streaming["stream_first"] = OUTPUT_STREAMING_STREAM_FIRST + + if OUTPUT_STREAMING_CONTEXT_SIZE >= OUTPUT_STREAMING_CHUNK_SIZE: + raise ValueError( + f"Invalid output-rails streaming constants: context_size=" + f"{OUTPUT_STREAMING_CONTEXT_SIZE} must be < chunk_size=" + f"{OUTPUT_STREAMING_CHUNK_SIZE}. Generating a config with this " + f"setting would cause one guardrail LLM call per streamed token." + ) logger.info("✓ Ensured required rails and streaming configuration structure") diff --git a/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251105_114631_config.yaml b/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251105_114631_config.yaml index 7565f99..4f992b3 100644 --- a/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251105_114631_config.yaml +++ b/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251105_114631_config.yaml @@ -42,7 +42,8 @@ rails: streaming: enabled: True chunk_size: 200 - context_size: 300 + # Must stay < chunk_size; see src/guardrails/rails_config.yaml + context_size: 50 stream_first: False prompts: diff --git a/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251112_205121_config.yaml b/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251112_205121_config.yaml index 7565f99..4f992b3 100644 --- a/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251112_205121_config.yaml +++ b/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251112_205121_config.yaml @@ -42,7 +42,8 @@ rails: streaming: enabled: True chunk_size: 200 - context_size: 300 + # Must stay < chunk_size; see src/guardrails/rails_config.yaml + context_size: 50 stream_first: False prompts: diff --git a/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251114_050437_config.yaml b/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251114_050437_config.yaml index 25e9001..d32d82c 100644 --- a/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251114_050437_config.yaml +++ b/src/optimization/optimized_modules/guardrails/guardrails_optimized_20251114_050437_config.yaml @@ -39,7 +39,8 @@ rails: streaming: enabled: true chunk_size: 200 - context_size: 300 + # Must stay < chunk_size; see src/guardrails/rails_config.yaml + context_size: 50 stream_first: false prompts: - task: self_check_input diff --git a/src/utils/cost_utils.py b/src/utils/cost_utils.py index b4c1a0e..e6376b9 100644 --- a/src/utils/cost_utils.py +++ b/src/utils/cost_utils.py @@ -163,6 +163,71 @@ def get_lm_usage_since(history_length_before: int) -> Dict[str, Any]: return usage_info +# Every guardrails self-check prompt opens with this phrase (see the +# self_check_input / self_check_output tasks in src/guardrails/rails_config.yaml). +# Matching on it lets us bill guardrail traffic separately from generation, which +# otherwise hides inside the same LM history window. +_GUARDRAIL_PROMPT_MARKER = "you are tasked with evaluating if a" + + +def _history_entry_text(item: Dict[str, Any]) -> str: + """Best-effort extraction of the prompt text from an LM history entry.""" + prompt = item.get("prompt") + if isinstance(prompt, str): + return prompt + + messages = item.get("messages") + if isinstance(messages, list): + parts = [] + for message in messages: + if isinstance(message, dict): + content = message.get("content") + if isinstance(content, str): + parts.append(content) + return "\n".join(parts) + + return "" + + +def _is_guardrail_entry(item: Dict[str, Any]) -> bool: + """Whether an LM history entry is a guardrails self-check call.""" + return _GUARDRAIL_PROMPT_MARKER in _history_entry_text(item).lower() + + +def get_lm_usage_since_split( + history_length_before: int, +) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Extract usage since a point, split into generation and guardrails buckets. + + During streaming, output-rail validation calls are interleaved with the + generation call in the same LM history window. Reporting them as one figure + makes runaway guardrail spend invisible, so we attribute each entry by its + prompt. + + Args: + history_length_before: The history length to measure from + + Returns: + ``(generation_usage, guardrails_usage)`` + """ + generation_usage = get_default_usage_dict() + guardrails_usage = get_default_usage_dict() + + try: + lm = dspy.settings.lm + if lm and hasattr(lm, "history"): + new_history = lm.history[history_length_before:] + guardrail_entries = [i for i in new_history if _is_guardrail_entry(i)] + generation_entries = [i for i in new_history if not _is_guardrail_entry(i)] + generation_usage = extract_cost_from_lm_history(generation_entries) + guardrails_usage = extract_cost_from_lm_history(guardrail_entries) + except Exception as e: + logger.warning(f"Failed to split usage info: {str(e)}") + + return generation_usage, guardrails_usage + + def get_default_usage_dict() -> Dict[str, Any]: """ Return a default usage dictionary with zero values. diff --git a/src/utils/stream_timeout.py b/src/utils/stream_timeout.py index 3278b7b..e382272 100644 --- a/src/utils/stream_timeout.py +++ b/src/utils/stream_timeout.py @@ -2,10 +2,40 @@ import asyncio from contextlib import asynccontextmanager -from typing import AsyncIterator +from typing import AsyncIterator, Optional, Union from src.llm_orchestrator_config.exceptions import StreamTimeoutError +# An SSE comment frame. Ignored by EventSource and by the notification server's +# relay (which only forwards `data: ` lines), but it is bytes on the wire, which +# is what keeps proxy idle timers from closing a slow stream. +HEARTBEAT_FRAME = ": ping\n\n" + + +class _StreamExhausted: + """Sentinel type marking a normally-completed source iterator. + + A dedicated class rather than a bare ``object()`` so that an ``isinstance`` + check narrows the value back to ``str`` for type checkers. + """ + + +_STREAM_EXHAUSTED = _StreamExhausted() + + +async def _next_or_sentinel( + iterator: AsyncIterator[str], +) -> Union[str, _StreamExhausted]: + """Advance an async iterator, returning a sentinel instead of raising at the end. + + Returning a sentinel keeps StopAsyncIteration out of the asyncio.Task that + wraps this call, where it would be an awkward special case. + """ + try: + return await iterator.__anext__() + except StopAsyncIteration: + return _STREAM_EXHAUSTED + @asynccontextmanager async def stream_timeout(seconds: int) -> AsyncIterator[None]: @@ -30,3 +60,66 @@ async def stream_timeout(seconds: int) -> AsyncIterator[None]: raise StreamTimeoutError( f"Stream exceeded maximum duration of {seconds} seconds" ) from e + + +async def with_heartbeat( + source: AsyncIterator[str], + heartbeat_interval: float, + idle_timeout: float, +) -> AsyncIterator[str]: + """ + Relay a stream, emitting SSE comment frames during quiet periods. + + Two problems are solved together. A long pause between chunks lets any proxy + on the path close the connection, so we keep writing; and a stream that has + genuinely stalled should fail fast rather than sit until the total-duration + cap expires, so we enforce an idle budget. + + Both timers measure the gap *between* chunks - a long answer that keeps + producing is never interrupted, however long it runs in total. + + Args: + source: The upstream chunk iterator. + heartbeat_interval: Seconds of quiet before emitting a heartbeat frame. + idle_timeout: Seconds of continuous quiet before giving up. + + Yields: + Chunks from ``source``, interleaved with ``HEARTBEAT_FRAME``. + + Raises: + StreamTimeoutError: If no chunk arrives for ``idle_timeout`` seconds. + """ + iterator = source.__aiter__() + pending: Optional["asyncio.Task[Union[str, _StreamExhausted]]"] = None + + try: + while True: + pending = asyncio.ensure_future(_next_or_sentinel(iterator)) + idle_elapsed = 0.0 + + while True: + try: + # Shielded so a heartbeat timeout does not cancel the pull; + # the same task is awaited again on the next pass. + chunk = await asyncio.wait_for( + asyncio.shield(pending), heartbeat_interval + ) + break + except asyncio.TimeoutError: + idle_elapsed += heartbeat_interval + if idle_elapsed >= idle_timeout: + pending.cancel() + raise StreamTimeoutError( + f"Stream produced no output for {idle_elapsed:.1f} " + f"seconds (idle limit {idle_timeout:.1f}s)" + ) from None + yield HEARTBEAT_FRAME + + if isinstance(chunk, _StreamExhausted): + return + + yield chunk + finally: + # Covers early consumer exit (client disconnect) as well as errors. + if pending is not None and not pending.done(): + pending.cancel()