Skip to main content

headless_lms_chatbot/azure_chatbot/azure/
sse.rs

1//! Reading the Azure Server-Sent Events stream: one line at a time, and far enough into a
2//! response to know which parser the rest of it belongs to.
3
4use futures::StreamExt;
5use tracing::trace;
6
7use super::protocol::{OutputItem, ResponseOutput, reported_azure_error};
8use super::transport::{ResponseLinesStream, ResponseStreamType};
9use crate::azure_chatbot::events::StreamItem;
10use crate::chatbot_error::ChatbotResult;
11use crate::prelude::*;
12
13/// Azure events that no parser needs to react to, or that some parser handles while another
14/// legitimately sees them. An event outside this list is logged as unexpected, so a name Azure
15/// starts sending has to be added here even when nothing acts on it.
16pub(crate) const ALL_EXPECTED_EVENTS: &[&str] = &[
17    "response.in_progress",
18    "response.queued",
19    "response.content_part.added",
20    "response.content_part.done",
21    "response.reasoning_summary_part.added",
22    "response.reasoning_summary_part.done",
23    "response.reasoning_summary_text.delta",
24    "response.reasoning_summary_text.done",
25    "response.reasoning_text.delta",
26    "response.reasoning_text.done",
27    "response.function_call_arguments.done",
28    "response.custom_tool_call_input.done",
29    "response.output_text.done",
30    "response.output_text.annotation.added",
31    "response.refusal.done",
32];
33
34/// One Azure SSE event name, classified once instead of by a separate `&str` match in each parser
35/// that reacts to it.
36///
37/// The three parsers deliberately disagree about the same event — `OutputTextDelta` is a hard
38/// error in the tool-call parser, the classification signal here, and ordinary traffic in the
39/// text parser — so this only says *what* the event is, never what to do about it. Each parser
40/// still matches its own subset of variants and keeps a catch-all for the rest, including
41/// [`Self::Other`].
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub(crate) enum AzureStreamEvent {
44    ResponseCreated,
45    ResponseCompleted,
46    OutputItemAdded,
47    OutputItemDone,
48    FunctionCallArgumentsDelta,
49    CustomToolCallInputDelta,
50    OutputTextDelta,
51    RefusalDelta,
52    Incomplete,
53    ErrorReported,
54    /// Anything not matched by name above, including traffic every parser is fine to ignore
55    /// (`response.in_progress`, reasoning summaries, …) and names outside [`ALL_EXPECTED_EVENTS`].
56    Other,
57}
58
59impl AzureStreamEvent {
60    pub(crate) fn from_wire(name: &str) -> Self {
61        match name {
62            "response.created" => Self::ResponseCreated,
63            "response.completed" => Self::ResponseCompleted,
64            "response.output_item.added" => Self::OutputItemAdded,
65            "response.output_item.done" => Self::OutputItemDone,
66            "response.function_call_arguments.delta" => Self::FunctionCallArgumentsDelta,
67            "response.custom_tool_call_input.delta" => Self::CustomToolCallInputDelta,
68            "response.output_text.delta" => Self::OutputTextDelta,
69            "response.refusal.delta" => Self::RefusalDelta,
70            "response.incomplete" => Self::Incomplete,
71            "response.error" | "error" | "response.failed" => Self::ErrorReported,
72            _ => Self::Other,
73        }
74    }
75
76    /// Which event a `data:` line belongs to: the `event:` line that preceded it, or, when none
77    /// did, the line's own `type` field.
78    ///
79    /// The one rule all three readers of the stream use, so the same line cannot count as, say,
80    /// Azure's finished copy of an item for one reader and its `added` copy for another. `type` is
81    /// only a fallback because it is optional on the wire.
82    pub(crate) fn of_data_line(
83        preceding_event: Option<Self>,
84        response_type: Option<&str>,
85    ) -> Option<Self> {
86        preceding_event.or_else(|| response_type.map(Self::from_wire))
87    }
88
89    /// Classifies an `event:` line, warning once if the name falls outside everything a parser
90    /// reacts to and [`ALL_EXPECTED_EVENTS`].
91    fn for_event_line(name: &str) -> Self {
92        let event = Self::from_wire(name);
93        if matches!(event, Self::Other) && !ALL_EXPECTED_EVENTS.contains(&name) {
94            warn!("Received unexpected event from Azure: Event: {}", name);
95        }
96        event
97    }
98}
99
100pub(crate) enum ParsedResponseLine {
101    Event(AzureStreamEvent),
102    Data(Box<ResponseOutput>),
103}
104
105/// The value of an SSE field, or `None` when the line is a different field. The space after the
106/// colon is optional in the SSE grammar, so a `data:{...}` line carries an item like any other.
107fn sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str> {
108    let value = line.strip_prefix(field)?.strip_prefix(':')?;
109    Some(value.strip_prefix(' ').unwrap_or(value))
110}
111
112impl ParsedResponseLine {
113    pub(crate) fn parse(input: &str) -> ChatbotResult<Option<Self>> {
114        if let Some(event_type) = sse_field(input, "event") {
115            Ok(Some(ParsedResponseLine::Event(
116                AzureStreamEvent::for_event_line(event_type),
117            )))
118        } else if let Some(data) = sse_field(input, "data") {
119            // The end-of-stream sentinel of the older completions API, which is not JSON.
120            if data.trim() == "[DONE]" {
121                return Ok(None);
122            }
123            let response_output = match serde_json::from_str::<ResponseOutput>(data) {
124                Ok(response_output) => response_output,
125                Err(e) => {
126                    tracing::error!(error = %e, "Failed to deserialize streamed response line from Azure");
127                    // The line itself can carry learner-facing text, so it is kept out of the
128                    // error-level log above and only traced, same as the rest of this module.
129                    tracing::trace!(raw_line = %data, "Raw line for the deserialization failure above");
130                    return Err(ChatbotError::from(e));
131                }
132            };
133            Ok(Some(ParsedResponseLine::Data(Box::new(response_output))))
134        } else {
135            Ok(None)
136        }
137    }
138}
139
140/// The head of an Azure response, read far enough to know which parser the rest of it belongs to.
141pub(crate) struct ClassifiedResponse<'a> {
142    pub(crate) response_id: String,
143    /// Every output item that arrived before the response was classified, in the order Azure sent
144    /// them. None of them is stored or forwarded yet; the caller does both.
145    pub(crate) items: Vec<StreamItem>,
146    /// The rest of the Azure stream, tagged with the parser it belongs to.
147    pub(crate) stream: ResponseStreamType<'a>,
148}
149
150/// Reads the head of `lines` until it is clear whether the round is a tool call or a text answer,
151/// and hands the rest of the stream on to the parser that suits it.
152///
153/// Errors if the response fails, arrives incomplete, or ends without classifying.
154pub(crate) async fn detect_response_kind<'a>(
155    mut lines: ResponseLinesStream<'a>,
156) -> ChatbotResult<ClassifiedResponse<'a>> {
157    let mut response_id: Option<String> = None;
158    let mut items: Vec<StreamItem> = Vec::new();
159    // If two event lines arrive back-to-back with no data line between them, the later one wins.
160    let mut preceding_event: Option<AzureStreamEvent> = None;
161
162    while let Some(line) = lines.next().await {
163        let line = line?;
164        let response_output = match ParsedResponseLine::parse(&line)? {
165            Some(ParsedResponseLine::Event(event)) => {
166                trace!("Event: {event:?}");
167                match &event {
168                    // Fallback for a round that starts streaming a delta without ever announcing
169                    // the item it belongs to.
170                    AzureStreamEvent::FunctionCallArgumentsDelta
171                    | AzureStreamEvent::CustomToolCallInputDelta => {
172                        return classified(response_id, items, ResponseStreamType::ToolCall(lines));
173                    }
174                    AzureStreamEvent::OutputTextDelta | AzureStreamEvent::RefusalDelta => {
175                        return classified(
176                            response_id,
177                            items,
178                            ResponseStreamType::TextResponse(lines),
179                        );
180                    }
181                    // todo: can add the incomplete reason for more info
182                    AzureStreamEvent::Incomplete => Err(chatbot_err!(
183                        ResponseIncomplete,
184                        format!(
185                            "Response incomplete. Response id: {}",
186                            response_id.as_deref().unwrap_or("not received")
187                        )
188                    ))?,
189                    _ => {}
190                }
191                preceding_event = Some(event);
192                continue;
193            }
194            Some(ParsedResponseLine::Data(response_output)) => response_output,
195            None => continue,
196        };
197
198        let event = AzureStreamEvent::of_data_line(
199            preceding_event.take(),
200            response_output.response_type.as_deref(),
201        );
202        match event {
203            Some(AzureStreamEvent::ErrorReported) => {
204                if let Some(error) = reported_azure_error(&response_output, response_id.as_deref())
205                {
206                    Err(error)?
207                } else {
208                    Err(chatbot_err!(
209                        UnexpectedProtocolShape,
210                        format!(
211                            "Response failed without receiving an API error. Response output: {:?} Response id: {}",
212                            &response_output,
213                            response_id.as_deref().unwrap_or("not received")
214                        )
215                    ))?
216                }
217            }
218            Some(AzureStreamEvent::ResponseCreated) => {
219                let response = response_output.response.ok_or(chatbot_err!(
220                    DeserializationError,
221                    "Expected response object"
222                ))?;
223                response_id = response.id;
224            }
225            Some(
226                item_event @ (AzureStreamEvent::OutputItemAdded | AzureStreamEvent::OutputItemDone),
227            ) => {
228                let received = response_output.item.ok_or(chatbot_err!(
229                    DeserializationError,
230                    "Expected response output item"
231                ))?;
232                let Some(item) = received.known() else {
233                    continue;
234                };
235                let parser = parser_for_item(&item);
236                items.push(StreamItem::Received {
237                    item,
238                    finished: item_event == AzureStreamEvent::OutputItemDone,
239                });
240                if let Some(parser) = parser {
241                    return classified(response_id, items, parser(lines));
242                }
243            }
244            _ => {}
245        }
246    }
247
248    // Reached when the stream ends before any event classifies the response as a tool call or a
249    // text answer — the normal outcome for a truncated response, not dead code.
250    Err(chatbot_err!(
251        StreamEndedEarly,
252        format!(
253            "The response received from Azure ended unexpectedly. Response id: {}",
254            response_id.as_deref().unwrap_or("not received")
255        )
256    ))
257}
258
259/// Which parser the rest of the round belongs to, going by the item it just announced, or `None`
260/// for an item that says nothing about it.
261///
262/// Reasoning and search items accompany a tool-call round and a text answer alike, so only a
263/// function call or a message decides. Reading the item rather than the first delta is what keeps
264/// a tool call that streams no arguments classifiable at all.
265fn parser_for_item<'a>(
266    item: &OutputItem,
267) -> Option<fn(ResponseLinesStream<'a>) -> ResponseStreamType<'a>> {
268    match item {
269        OutputItem::FunctionCall { .. } => Some(ResponseStreamType::ToolCall),
270        OutputItem::Message { .. } => Some(ResponseStreamType::TextResponse),
271        OutputItem::Reasoning { .. }
272        | OutputItem::AzureAiSearchCall { .. }
273        | OutputItem::AzureAiSearchCallOutput { .. }
274        | OutputItem::FunctionCallOutput { .. } => None,
275    }
276}
277
278/// The classification, once the round is known, failing if Azure never named the response.
279fn classified<'a>(
280    response_id: Option<String>,
281    items: Vec<StreamItem>,
282    stream: ResponseStreamType<'a>,
283) -> ChatbotResult<ClassifiedResponse<'a>> {
284    let response_id = response_id.ok_or(chatbot_err!(
285        StreamInvariantViolation,
286        "No response_id found! This should never happen!"
287    ))?;
288    Ok(ClassifiedResponse {
289        response_id,
290        items,
291        stream,
292    })
293}