Skip to main content

headless_lms_chatbot/
azure_chatbot.rs

1use std::collections::HashMap;
2use std::pin::Pin;
3use std::sync::{
4    Arc,
5    atomic::{self, AtomicBool},
6};
7use std::task::{Context, Poll};
8
9use bytes::Bytes;
10use chrono::Utc;
11use futures::stream::{BoxStream, Peekable};
12use futures::{Stream, StreamExt, TryStreamExt};
13use headless_lms_base::config::ApplicationConfiguration;
14use headless_lms_models::chatbot_configurations::{ReasoningEffortLevel, VerbosityLevel};
15use headless_lms_models::chatbot_conversation_message_messages::{
16    ChatbotConversationMessageMessage, MessageRole,
17};
18use headless_lms_models::chatbot_conversation_messages::{
19    self, ChatbotConversationMessage, Message,
20};
21use pin_project::pin_project;
22use serde::{Deserialize, Serialize};
23use sqlx::PgPool;
24use tokio::{io::AsyncBufReadExt, sync::Mutex};
25use tokio_stream::wrappers::LinesStream;
26use tokio_util::io::StreamReader;
27use tracing::trace;
28use url::Url;
29use utoipa::ToSchema;
30
31use crate::chatbot_error::ChatbotResult;
32use crate::chatbot_tools::provider_tools::azure_ai_search::get_azure_ai_search_tool_definition;
33use crate::chatbot_tools::{
34    AzureLLMToolDefinition, call_chatbot_tool, get_chatbot_tool_definitions,
35};
36use crate::citations::chatbot_cited_documents_to_citations;
37use crate::llm_utils::{
38    APIInputMessage, APIOutputMessage, MessageContent, estimate_tokens, get_params_for_model,
39    make_streaming_llm_request,
40};
41
42use crate::prelude::*;
43
44pub const CONTENT_FIELD_SEPARATOR: &str = ",|||,";
45
46/// These are the events we expect to receive from Azure API but will not handle.
47/// This list doesn't include the events we explicitly handle.
48const ALL_EXPECTED_EVENTS: &[&str] = &[
49    "response.in_progress",
50    "response.queued",
51    "response.created",
52    "response.output_item.added",
53    "response.output_item.done",
54    "response.content_part.added",
55    "response.content_part.done",
56    // we can stream reasoning summary text with these
57    "response.reasoning_summary_part.added",
58    "response.reasoning_summary_part.done",
59    "response.reasoning_summary_text.delta",
60    "response.reasoning_summary_text.done",
61    "response.reasoning_text.delta",
62    "response.reasoning_text.done",
63    "response.function_call_arguments.delta",
64    "response.function_call_arguments.done",
65    "response.custom_tool_call_input.delta",
66    "response.custom_tool_call_input.done",
67    "response.output_text.done",
68    "response.refusal.delta",
69    "response.refusal.done",
70];
71
72/// Appended to the system prompt when course-material search is enabled, to ground answers
73/// in retrieved course material.
74const SEARCH_GROUNDING_INSTRUCTION: &str = "\n\nSearch the course material with the azure_ai_search tool before answering, and ground your answer in the results with citations. Put only what you want to find in the query; the search is already limited to this course, so don't include the course name. Searching more than once is fine when it helps — to cover distinct sub-questions or angles, to refine when the first results don't answer, or when a follow-up or new instruction needs material you don't already have. When one search already answers, stop there. If you need more information about a specific document or a topic covered in it, use the document_lookup tool to retrieve the full document. Skip searching only for messages that don't need course material, like greetings or thanks. If you need more information about the course, like what pages and chapters are in it, use the course_structure tool.";
75
76enum ParsedResponseLine {
77    Event(String),
78    Data(Box<ResponseOutput>),
79}
80
81impl ParsedResponseLine {
82    pub fn parse(input: &str) -> ChatbotResult<Option<Self>> {
83        if input.starts_with("event: ") {
84            let event_type = input.trim_start_matches("event: ").to_string();
85            Ok(Some(ParsedResponseLine::Event(event_type)))
86        } else if input.starts_with("data: ") {
87            let data = input.trim_start_matches("data: ").to_string();
88            let response_output = match serde_json::from_str::<ResponseOutput>(&data) {
89                Ok(response_output) => response_output,
90                Err(e) => {
91                    // Log the raw line so deserialization failures against the Azure response
92                    // schema can be diagnosed without reproducing them.
93                    tracing::error!(
94                        raw_line = %data,
95                        error = %e,
96                        "Failed to deserialize streamed response line from Azure"
97                    );
98                    return Err(ChatbotError::from(e));
99                }
100            };
101            Ok(Some(ParsedResponseLine::Data(Box::new(response_output))))
102        } else {
103            Ok(None)
104        }
105    }
106}
107
108/// Context about the user and course for a chatbot interaction.
109/// Passed to tool implementations so they can access user-specific data.
110pub struct ChatbotUserContext {
111    pub user_id: Option<Uuid>,
112    pub course_id: Option<Uuid>,
113    pub course_name: Option<String>,
114}
115
116#[derive(Deserialize, Serialize, Debug)]
117pub struct ContentFilterResults {
118    pub hate: Option<ContentFilter>,
119    pub self_harm: Option<ContentFilter>,
120    pub sexual: Option<ContentFilter>,
121    pub violence: Option<ContentFilter>,
122    //pub jailbreak: Option<ContentFilter>,
123}
124
125#[derive(Deserialize, Serialize, Debug)]
126pub struct ContentFilter {
127    pub blocked: bool,
128    pub source_type: ContentFilterSource,
129    pub content_filter_results: Vec<ContentFilterResults>,
130}
131#[derive(Deserialize, Serialize, Debug)]
132pub struct ContentFilterResult {
133    pub filtered: bool,
134    pub severity: String,
135}
136
137#[derive(Deserialize, Serialize, Debug)]
138#[serde(rename_all = "snake_case")]
139pub enum ContentFilterSource {
140    Prompt,
141    Completion,
142}
143
144/// Response received from LLM API
145#[derive(Deserialize, Serialize, Debug)]
146pub struct Response {
147    pub id: Option<String>,
148    pub error: Option<ResponseError>,
149}
150
151/// Error object returned by the LLM API on a failed response. Fields are optional so any
152/// error shape deserializes rather than crashing the stream parser.
153#[derive(Deserialize, Serialize, Debug, Clone)]
154pub struct ResponseError {
155    pub code: Option<String>,
156    pub message: Option<String>,
157    #[serde(rename = "type")]
158    pub error_type: Option<String>,
159    pub param: Option<String>,
160}
161
162impl std::fmt::Display for ResponseError {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        write!(
165            f,
166            "{}: {} (code: {}, param: {})",
167            self.error_type.as_deref().unwrap_or("Error"),
168            self.message.as_deref().unwrap_or("unknown error"),
169            self.code.as_deref().unwrap_or("none"),
170            self.param.as_deref().unwrap_or("none")
171        )
172    }
173}
174
175/// Incomplete response received from LLM API
176#[derive(Deserialize, Serialize, Debug)]
177pub struct IncompleteResponse {
178    pub id: String,
179    pub incomplete_details: IncompleteReason,
180    pub content_filters: Vec<ContentFilter>,
181}
182
183/// Response received from LLM API
184#[derive(Deserialize, Serialize, Debug)]
185pub struct IncompleteReason {
186    pub reason: String,
187}
188
189/// Streamed token of the response text
190#[derive(Deserialize, Serialize, Debug)]
191pub struct ResponseOutput {
192    /// The event type of this response
193    // Optional so a streamed `data:` line that omits `type` still deserializes and is ignored,
194    // rather than aborting the whole chat stream.
195    #[serde(rename = "type")]
196    pub response_type: Option<String>, // for examples check out ALL_EXPECTED_EVENTS
197    pub delta: Option<String>,
198    pub item: Option<OutputItem>,
199    pub response: Option<Response>,
200    pub incomplete_response: Option<IncompleteResponse>,
201    pub error: Option<ResponseError>,
202}
203
204#[derive(Deserialize, Serialize, Debug, Clone)]
205#[serde(tag = "type")]
206#[serde(rename_all = "snake_case")]
207pub enum OutputItem {
208    Message {
209        response_id: String,
210        role: MessageRole,
211        content: MessageContent,
212        // todo phase for reasoning preamble
213        #[serde(skip_serializing_if = "Option::is_none")]
214        phase: Option<MessagePhase>,
215    },
216    Reasoning {
217        response_id: String,
218        id: String,
219        summary: Vec<ReasoningOutput>,
220    },
221    AzureAiSearchCall {
222        response_id: String,
223        call_id: String,
224        /// JSON string
225        arguments: String,
226    },
227    AzureAiSearchCallOutput {
228        response_id: String,
229        call_id: String,
230        /// JSON string
231        output: String,
232    },
233    FunctionCall {
234        response_id: String,
235        call_id: String,
236        #[serde(rename = "name")]
237        tool_name: String,
238        /// JSON string
239        arguments: String,
240    },
241    FunctionCallOutput {
242        response_id: String,
243        call_id: String,
244        output: String,
245    },
246}
247
248/// Phase determines how to react to the message. Commentary-phase can have e.g.
249/// a reasoning preamble, and final answer is self-explanatory.
250#[derive(Deserialize, Serialize, Debug, Clone)]
251#[serde(rename_all = "snake_case")]
252pub enum MessagePhase {
253    Commentary,
254    FinalAnswer,
255}
256
257impl From<StreamItem> for ChatbotChatStreamEvent {
258    fn from(value: StreamItem) -> Self {
259        match value {
260            StreamItem {
261                item: OutputItem::Reasoning { id, .. },
262                finished,
263            } => ChatbotChatStreamEvent::Reasoning {
264                finished,
265                reasoning_id: id,
266            },
267            StreamItem {
268                item:
269                    OutputItem::AzureAiSearchCall {
270                        arguments, call_id, ..
271                    },
272                finished,
273            } => ChatbotChatStreamEvent::ToolCall {
274                tool_name: Some("azure_ai_search".to_string()),
275                arguments: Some(arguments),
276                tool_call_id: call_id,
277                finished,
278            },
279            StreamItem {
280                item:
281                    OutputItem::FunctionCall {
282                        tool_name,
283                        arguments,
284                        call_id,
285                        ..
286                    },
287                ..
288            } => ChatbotChatStreamEvent::ToolCall {
289                tool_name: Some(tool_name),
290                arguments: Some(arguments),
291                tool_call_id: call_id,
292                finished: false,
293            },
294            StreamItem {
295                item: OutputItem::AzureAiSearchCallOutput { call_id, .. },
296                ..
297            } => ChatbotChatStreamEvent::ToolCall {
298                tool_name: Some("azure_ai_search".to_string()),
299                arguments: None,
300                tool_call_id: call_id,
301                finished: true,
302            },
303            StreamItem {
304                item: OutputItem::FunctionCallOutput { call_id, .. },
305                ..
306            } => ChatbotChatStreamEvent::ToolCall {
307                // tool name and arguments are ignored in the frontend. this StreamEvent
308                // just signals that the tool call has finished.
309                tool_name: None,
310                arguments: None,
311                tool_call_id: call_id,
312                finished: true,
313            },
314            StreamItem {
315                item: OutputItem::Message { .. },
316                ..
317            } => ChatbotChatStreamEvent::Invalid,
318        }
319    }
320}
321
322#[derive(Deserialize, Serialize, Debug, Clone)]
323#[serde(tag = "type")]
324#[serde(rename_all = "snake_case")]
325pub enum InputItem {
326    Message {
327        role: MessageRole,
328        content: MessageContent,
329    },
330    FunctionCall {
331        call_id: String,
332        #[serde(rename = "name")]
333        tool_name: String,
334        arguments: String,
335    },
336    FunctionCallOutput {
337        call_id: String,
338        output: String,
339    },
340    Reasoning {
341        id: String,
342        summary: Vec<ReasoningOutput>,
343    },
344}
345
346#[derive(Deserialize, Serialize, Debug, Clone)]
347pub struct AISearchOutput {
348    pub get_urls: Vec<Url>,
349}
350
351#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
352#[serde(rename_all = "snake_case")]
353pub enum LLMToolChoice {
354    Auto,
355    None,
356}
357
358#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
359pub struct ThinkingParams {
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub reasoning: Option<Reasoning>,
362}
363
364#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
365pub struct RequestTextOptions {
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub verbosity: Option<VerbosityLevel>,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub format: Option<LLMRequestResponseFormatParam>,
370}
371#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
372pub struct Reasoning {
373    pub effort: ReasoningEffortLevel,
374    /// Option to generate a reasoning summary with desired level of info
375    pub summary: Option<SummaryType>,
376}
377
378#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
379#[serde(untagged)]
380pub enum SummaryType {
381    Concise,
382    Detailed,
383    Auto,
384}
385
386#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
387pub struct ReasoningOutput {
388    #[serde(rename = "type")]
389    pub output_type: String, //summary_text
390    pub text: String,
391}
392
393#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
394pub struct NonThinkingParams {
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub temperature: Option<f32>,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub top_p: Option<f32>,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub frequency_penalty: Option<f32>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub presence_penalty: Option<f32>,
403}
404
405#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
406pub struct MistralParams {
407    // todo
408    pub test: bool,
409}
410
411#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
412#[serde(untagged)]
413pub enum LLMRequestParams {
414    GPTThinking(ThinkingParams),
415    GPTNonThinking(NonThinkingParams),
416    Mistral(MistralParams),
417}
418
419#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
420#[serde(rename_all = "snake_case")]
421pub enum JSONType {
422    JsonSchema,
423    Object,
424    Array,
425    String,
426}
427
428/// Defines LLM structured output shape and types
429#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
430#[serde(rename_all = "camelCase")]
431pub struct Schema {
432    #[serde(rename = "type")]
433    /// Type of the schema, should be Object
434    pub type_field: JSONType,
435    pub properties: HashMap<String, SchemaPropertyType>,
436    /// All 'properties' keys must be included in this 'required' list
437    pub required: Vec<String>,
438    /// additionalProperties should always be 'false'
439    pub additional_properties: bool,
440}
441
442#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
443#[serde(untagged)]
444pub enum SchemaPropertyType {
445    ArrayProperty(ArrayProperty),
446    Object(Schema),
447    Item(JsonItem),
448}
449
450#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
451pub struct ArrayProperty {
452    #[serde(rename = "type")]
453    pub type_field: JSONType,
454    pub items: ArrayItem,
455}
456
457#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
458#[serde(untagged)]
459pub enum ArrayItem {
460    Schema(Schema),
461    JsonItem(JsonItem),
462}
463
464#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
465pub struct JsonItem {
466    #[serde(rename = "type")]
467    pub type_field: JSONType,
468}
469
470#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
471pub struct LLMRequestResponseFormatParam {
472    #[serde(rename = "type")]
473    pub format_type: JSONType, //should be JsonSchema
474    pub name: String,
475    pub schema: Schema,
476    pub strict: bool, // should be true
477}
478
479#[derive(Serialize, Deserialize, Debug, Clone)]
480pub struct LLMRequest {
481    pub input: Vec<APIInputMessage>,
482    pub model: String,
483    #[serde(skip_serializing_if = "Vec::is_empty", default)]
484    pub tools: Vec<AzureLLMToolDefinition>,
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub tool_choice: Option<LLMToolChoice>,
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub parallel_tool_calls: Option<bool>,
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub max_output_tokens: Option<i32>,
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub text: Option<RequestTextOptions>,
493    #[serde(flatten)]
494    pub params: LLMRequestParams,
495}
496
497impl LLMRequest {
498    pub async fn build_and_insert_incoming_user_message_to_db(
499        conn: &mut PgConnection,
500        chatbot_configuration_id: Uuid,
501        conversation_id: Uuid,
502        message: &str,
503        app_config: &ApplicationConfiguration,
504    ) -> ChatbotResult<(Self, i32)> {
505        let configuration =
506            models::chatbot_configurations::get_by_id(conn, chatbot_configuration_id).await?;
507
508        let model = models::chatbot_configurations_models::get_by_chatbot_configuration_id(
509            conn,
510            chatbot_configuration_id,
511        )
512        .await?;
513
514        let conversation_messages =
515            models::chatbot_conversation_messages::get_by_conversation_id(conn, conversation_id)
516                .await?;
517
518        let new_order_number = conversation_messages
519            .iter()
520            .map(|m| m.order_number)
521            .max()
522            .unwrap_or(0)
523            + 1;
524
525        let new_message = models::chatbot_conversation_messages::insert(
526            conn,
527            ChatbotConversationMessage {
528                id: Uuid::new_v4(),
529                order_number: new_order_number,
530                created_at: Utc::now(),
531                updated_at: Utc::now(),
532                deleted_at: None,
533                conversation_id,
534                message: Message::Text(ChatbotConversationMessageMessage {
535                    text: message.to_string(),
536                    message_role: MessageRole::User,
537                    message_is_complete: true,
538                    used_tokens: estimate_tokens(message),
539                    ..Default::default()
540                }),
541            },
542        )
543        .await?;
544
545        let mut api_chat_messages: Vec<APIInputMessage> = conversation_messages
546            .into_iter()
547            .filter_map(|m| match m.message {
548                Message::Reasoning(..) => None,
549                _ => Some(APIInputMessage::try_from(m)),
550            })
551            .collect::<ChatbotResult<Vec<_>>>()?;
552
553        // put new user message into the messages list
554        api_chat_messages.push(new_message.clone().try_into()?);
555
556        let mut system_prompt = configuration.prompt.clone();
557        if configuration.use_azure_search {
558            system_prompt.push_str(SEARCH_GROUNDING_INSTRUCTION);
559        }
560
561        api_chat_messages.insert(
562            0,
563            APIInputMessage {
564                message_type: InputItem::Message {
565                    role: MessageRole::System,
566                    content: MessageContent::Text(system_prompt),
567                },
568            },
569        );
570
571        let mut tools = if configuration.use_tools {
572            get_chatbot_tool_definitions()
573        } else {
574            Vec::new()
575        };
576
577        if configuration.use_azure_search {
578            tools.extend(vec![AzureLLMToolDefinition::Search(
579                get_azure_ai_search_tool_definition(
580                    app_config,
581                    configuration.course_id.ok_or_else(|| {
582                        chatbot_err!(Other, "Course id is missing from the chatbot configuration")
583                    })?,
584                    configuration.use_semantic_reranking,
585                )?,
586            )]);
587        };
588
589        let tool_choice = if configuration.use_azure_search || configuration.use_tools {
590            Some(LLMToolChoice::Auto)
591        } else {
592            None
593        };
594
595        let serialized_messages = serde_json::to_string(&api_chat_messages)?;
596        let request_estimated_tokens = estimate_tokens(&serialized_messages);
597
598        let params = get_params_for_model(&model.model, &model.model_type, Some(&configuration));
599
600        Ok((
601            Self {
602                input: api_chat_messages,
603                model: model.model,
604                max_output_tokens: Some(configuration.max_output_tokens),
605                tools,
606                tool_choice,
607                parallel_tool_calls: Some(true),
608                text: Some(RequestTextOptions {
609                    verbosity: Some(configuration.verbosity),
610                    format: None,
611                }),
612                params,
613            },
614            request_estimated_tokens,
615        ))
616    }
617}
618
619#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
620#[serde(tag = "type", content = "data")]
621pub enum ChatbotChatStreamEvent {
622    Delta {
623        text: String,
624        message_id: Uuid,
625    },
626    Reasoning {
627        finished: bool,
628        reasoning_id: String,
629    },
630    ToolCall {
631        tool_name: Option<String>,
632        arguments: Option<String>,
633        tool_call_id: String,
634        finished: bool,
635    },
636    Done,
637    Error(StreamEventError),
638    /// If a ChatbotChatStreamEvent has been constructed from a StreamItem etc.,
639    /// not all variants are valid ChatbotChatStreamEvents and shouldn't be sent to
640    /// the frontend in the stream. In that case, use this variant.
641    Invalid,
642}
643
644#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
645pub struct StreamEventError {
646    message: String,
647    details: Option<String>,
648}
649
650/// Custom stream that encapsulates both the response stream and the cancellation guard. Makes sure that the guard is always dropped when the stream is dropped.
651#[pin_project]
652struct GuardedStream<S> {
653    guard: RequestCancelledGuard,
654    #[pin]
655    stream: S,
656}
657
658impl<S> GuardedStream<S> {
659    fn new(guard: RequestCancelledGuard, stream: S) -> Self {
660        Self { guard, stream }
661    }
662}
663
664impl<S> Stream for GuardedStream<S>
665where
666    S: Stream<Item = ChatbotResult<Bytes>> + Send,
667{
668    type Item = S::Item;
669
670    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
671        let this = self.project();
672        let polled = this.stream.poll_next(cx);
673        // Log stream errors here in the clean format; actix's dispatcher otherwise only
674        // surfaces them as a terse Display line once the error is in the response body.
675        if let Poll::Ready(Some(Err(error))) = &polled {
676            error!("Chatbot response stream error:\n{error:?}");
677        }
678        polled
679    }
680}
681
682/// A LinesStream that is peekable. Needed to determine which type of LLM response is
683/// being received.
684type PeekableLinesStream<'a> = Pin<
685    Box<Peekable<LinesStream<StreamReader<BoxStream<'a, Result<Bytes, std::io::Error>>, Bytes>>>>,
686>;
687pub enum ResponseStreamType<'a> {
688    Toolcall(PeekableLinesStream<'a>),
689    TextResponse(PeekableLinesStream<'a>),
690}
691
692struct RequestCancelledGuard {
693    response_message_id: Arc<Mutex<Uuid>>,
694    received_string: Arc<Mutex<Vec<String>>>,
695    pool: PgPool,
696    done: Arc<AtomicBool>,
697    request_estimated_tokens: i32,
698}
699
700impl Drop for RequestCancelledGuard {
701    fn drop(&mut self) {
702        if self.done.load(atomic::Ordering::Relaxed) {
703            return;
704        }
705        warn!("Request was not cancelled. Cleaning up.");
706        let response_message_id = self.response_message_id.clone();
707        let received_string = self.received_string.clone();
708        let pool = self.pool.clone();
709        let request_estimated_tokens = self.request_estimated_tokens;
710        tokio::spawn(async move {
711            info!("Verifying the received message has been handled");
712            let mut conn = pool.acquire().await.expect("Could not acquire connection");
713            let full_response_text = received_string.lock().await;
714            let id = response_message_id.lock().await.to_owned();
715            if full_response_text.is_empty() {
716                info!("No response received. Deleting the response message");
717                models::chatbot_conversation_messages::delete(&mut conn, id)
718                    .await
719                    .expect("Could not delete response message");
720                return;
721            }
722            info!("Response received but not completed. Saving the text received so far.");
723            let full_response_as_string = full_response_text.join("");
724            let estimated_cost = estimate_tokens(&full_response_as_string);
725            info!(
726                "End of chatbot response stream. Estimated cost: {}. Response: {}",
727                estimated_cost, full_response_as_string
728            );
729
730            // Update with request_estimated_tokens + estimated_cost
731            models::chatbot_conversation_message_messages::update(
732                &mut conn,
733                id,
734                &full_response_as_string,
735                true,
736                request_estimated_tokens + estimated_cost,
737            )
738            .await
739            .expect("Could not update response message");
740        });
741    }
742}
743
744/// For saving output items that are not text messages or function calls, i.e. that
745/// don't need further processing and are not streamed to the user.
746/// Saves reasoning and Azure AI Search items.
747pub async fn process_output_item(
748    conn: &mut PgConnection,
749    item: OutputItem,
750    conversation_id: Uuid,
751    app_config: &ApplicationConfiguration,
752) -> ChatbotResult<ChatbotConversationMessage> {
753    match item {
754        OutputItem::AzureAiSearchCall { .. } | OutputItem::Reasoning { .. } => {
755            let message = APIOutputMessage { message_type: item }
756                .to_chatbot_conversation_message(conversation_id)?;
757
758            ChatbotResult::Ok(chatbot_conversation_messages::insert(conn, message).await?)
759        }
760        OutputItem::AzureAiSearchCallOutput {
761            call_id,
762            output,
763            response_id,
764        } => {
765            let search_output: AISearchOutput = serde_json::from_str(&output)?;
766            let api_key = if let Some(azure_config) = &app_config.azure_configuration
767                && let Some(search_config) = &azure_config.search_config
768            {
769                &search_config.search_api_key
770            } else {
771                return ChatbotResult::Err(chatbot_err!(
772                    Other,
773                    "Azure search configuration not found, cannot process Azure AI search output item.".to_string()
774                ));
775            };
776            let get_urls = search_output.get_urls.to_owned();
777
778            let message = APIOutputMessage {
779                message_type: OutputItem::AzureAiSearchCallOutput {
780                    call_id,
781                    output,
782                    response_id: response_id.to_owned(),
783                },
784            }
785            .to_chatbot_conversation_message(conversation_id)?;
786
787            let conversation_message = chatbot_conversation_messages::insert(conn, message).await?;
788
789            let res = chatbot_cited_documents_to_citations(
790                conn,
791                app_config.test_chatbot,
792                get_urls,
793                api_key,
794                conversation_message.id,
795                conversation_id,
796            )
797            .await;
798
799            if let Err(e) = res {
800                error!(
801                    "Failed to save cited documents in the DB. Response id: {response_id} Error: {e}"
802                );
803            };
804
805            ChatbotResult::Ok(conversation_message)
806        }
807        OutputItem::Message { ref content, .. } => {
808            if let MessageContent::Refusal(..) = content {
809                let message = APIOutputMessage {
810                    message_type: item.clone(),
811                }
812                .to_chatbot_conversation_message(conversation_id)?;
813
814                ChatbotResult::Ok(chatbot_conversation_messages::insert(conn, message).await?)
815            } else {
816                // this chunk has a text message and should be streamed!
817                Err(chatbot_err!(
818                    StreamingError,
819                    "Unexpected message output item, it should have been streamed.".to_string()
820                ))
821            }
822        }
823        OutputItem::FunctionCall { .. } => {
824            // this chunk has tool call data and it should already be saved!!
825            Err(chatbot_err!(
826                StreamingError,
827                "Unexpected function call output item, it should have been processed.".to_string()
828            ))
829        }
830        OutputItem::FunctionCallOutput { .. } => {
831            // this chunk has tool output data
832            // we shouldn't be receiving it from the LLM!
833            // tool output is created by us!
834            Err(chatbot_err!(
835                StreamingError,
836                "Unexpected function call output item, this shouldn't happen.".to_string()
837            ))
838        }
839    }
840}
841
842/// Streams and parses a LLM response from Azure that contains function calls.
843/// Calls the functions and yields a Vec of function results to be sent to Azure.
844/// Consumes the lines (stream), because it ends when a custom function call is made.
845/// Returns a stream to be consumed in the caller.
846async fn parse_tool<'a>(
847    conn: &'a mut PgConnection,
848    mut lines: PeekableLinesStream<'a>,
849    conversation_id: Uuid,
850    user_context: &'a ChatbotUserContext,
851) -> BoxStream<'a, ChatbotResult<StreamEvent<'a>>> {
852    let mut function_name_id_args: Vec<(String, String, String)> = vec![];
853    let mut messages = vec![];
854    let mut common_response_id: Option<String> = None;
855    let mut response_received = false;
856
857    trace!("Parsing tool calls...");
858
859    Box::pin(async_stream::try_stream! {
860    while let Some(val) = lines.next().await {
861        let line = val?;
862        let response_output: ResponseOutput = match ParsedResponseLine::parse(&line)? {
863            Some(ParsedResponseLine::Event(event_type)) => {
864                trace!("Event: {event_type}");
865                match event_type.as_str() {
866                    "response.completed" | "response.incomplete" => {
867                        response_received = true;
868                    }
869                    "response.output_text.delta" => {
870                        Err(chatbot_err!(StreamingError,
871                            "Error: Received response text while parsing tool calls. Either the tool call parsing failed or the LLM responded in an unexpected way."
872                        ))?
873                    }
874                    "response.error" | "response.failed" | "error" => {
875                        // error is logged in the next iteration
876                     }
877                    _ => {
878                        if !ALL_EXPECTED_EVENTS.contains(&event_type.as_str()) {
879                            warn!("Received unexpected event from Azure: Event: {}", event_type);
880                        };
881                    }
882                };
883                continue;
884            }
885            Some(ParsedResponseLine::Data(data)) => *data,
886            None => {
887                continue;
888            }
889        };
890
891        // Surface any error the API reports (e.g. response.error, response.failed)
892        // instead of continuing. Normal responses carry no error object.
893        if let Some(response) = response_output.response
894        && let Some(err) = response.error
895        {
896            let mut error = chatbot_err!(
897                StreamingError,
898                format!("Error received from Azure API. Response id: {}", response.id.as_deref().unwrap_or("not received"))
899            );
900            error.add_azure_source(err);
901            Err(error)?
902        };
903        // Surface the error in case there is no response object, just an error
904        if let Some(err) = response_output.error {
905            let mut error = chatbot_err!(
906                StreamingError,
907                format!("Error received from Azure API. Response id: {}", common_response_id.as_deref().unwrap_or("not received"))
908            );
909            error.add_azure_source(err);
910            Err(error)?
911
912        };
913
914        if response_received {
915            // the stream ended
916            if let Some(response) = &response_output.incomplete_response {
917                // todo: can add content filter results for more info
918                Err(chatbot_err!(StreamingError,
919                    format!("The LLM response is incomplete. Reason: {}", response.incomplete_details.reason)
920                ))?
921            };
922            if function_name_id_args.is_empty() {
923                Err(chatbot_err!(StreamingError,
924                    "The LLM response was supposed to contain function calls, but no function calls were found"
925                ))?
926            }
927            let Some(response_id) = &common_response_id else {
928                Err(chatbot_err!(StreamingError,
929                    "Received tool response but response id not found, this shouldn't happen."
930                ))?
931            };
932
933            for (name, id, args) in function_name_id_args.into_iter() {
934                let mut tx = conn.begin().await.map_err(ChatbotError::from)?;
935                let tool_result = call_chatbot_tool(&mut tx, &name, args, user_context).await?;
936
937                let tool_call_message = APIOutputMessage {
938                    message_type: OutputItem::FunctionCall {
939                        response_id: response_id.to_owned(),
940                        call_id: id.to_owned(),
941                        tool_name: name.to_owned(),
942                        arguments: tool_result.arguments,
943                    },
944                };
945                chatbot_conversation_messages::insert(
946                    &mut tx,
947                    tool_call_message.to_chatbot_conversation_message(conversation_id)?,
948                )
949                .await?;
950
951                let function_call_output = OutputItem::FunctionCallOutput {
952                        call_id: id.to_owned(),
953                        output: tool_result.output,
954                        response_id: response_id.to_owned(),
955                    };
956                let output_message = APIOutputMessage {
957                    message_type: function_call_output.to_owned(),
958                };
959                chatbot_conversation_messages::insert(
960                    &mut tx,
961                    output_message.to_chatbot_conversation_message(conversation_id)?,
962                )
963                .await?;
964                tx.commit().await.map_err(ChatbotError::from)?;
965
966                messages.extend([tool_call_message, output_message]);
967
968                yield StreamEvent::Item(StreamItem {
969                    item: function_call_output,
970                    finished: true,
971                });
972            }
973
974            let input_messages = messages.into_iter().map(APIInputMessage::from).collect::<Vec<APIInputMessage>>();
975            yield StreamEvent::Messages(input_messages);
976            break;
977        } else if let Some(item) = response_output.item {
978            match item.to_owned() {
979                OutputItem::FunctionCall {
980                    call_id,
981                    tool_name,
982                    arguments,
983                    response_id,
984                } => {
985                    common_response_id = Some(response_id);
986                    function_name_id_args.push((
987                        tool_name,
988                        call_id,
989                        arguments,
990                    ));
991                    yield StreamEvent::Item(StreamItem { item, finished: false });
992                }
993                OutputItem::Message { content, .. } => {
994                    if let MessageContent::Refusal(..) = content {
995                        yield StreamEvent::Refusal(content.get_content_text());
996                        messages.push(APIOutputMessage { message_type: item });
997
998                    } else {
999                    Err(chatbot_err!(
1000                        StreamingError,
1001                        "Error: unexpected message item !!!".to_string()
1002                    ))?}
1003                },
1004                _ => {
1005                    let finished = response_output.response_type.as_deref() == Some("response.output_item.done");
1006                    yield StreamEvent::Item(StreamItem { item: item.to_owned(), finished});
1007
1008                    // add this output item to the messages to be included in the next
1009                    // LLMRequest
1010                    messages.push(APIOutputMessage { message_type: item });
1011                }
1012            }
1013        }
1014    }})
1015}
1016
1017/// Stream from Azure and return the stream when a text response or tool call response is detected.
1018/// Tool calls and text responses are processed later with differing logic.
1019/// Returns a stream to be consumed in the caller.
1020/// Yields the lines (stream) argument, which is the Azure stream.
1021fn stream_and_detect_response_stream_type<'a>(
1022    mut lines: PeekableLinesStream<'a>,
1023) -> impl Stream<Item = ChatbotResult<StreamEvent<'a>>> {
1024    let mut response_id: Option<String> = None;
1025    let mut response_created_incoming = false;
1026    let mut error_incoming = false;
1027    let mut output_item_added = false;
1028    let mut output_item_done = false;
1029
1030    Box::pin(async_stream::try_stream! {
1031    loop {
1032        let line_res = lines.next().await;
1033        match line_res {
1034            None => {
1035                break;
1036            }
1037            Some(val) => {
1038                let line = val?;
1039                let response_output = match ParsedResponseLine::parse(&line)? {
1040                    Some(ParsedResponseLine::Event(event_type)) => {
1041                        trace!("Event: {event_type}");
1042                        match event_type.as_str() {
1043                            "response.created" => {
1044                                response_created_incoming = true;
1045                            }
1046                            "response.output_item.added" => {
1047                                output_item_added = true;
1048                            }
1049                            "response.output_item.done" => {
1050                                output_item_done = true;
1051                            }
1052                            "response.function_call_arguments.delta" | "response.custom_tool_call_input.delta" => {
1053                                if let Some(id) = &response_id {
1054                                    yield StreamEvent::ResponseIdStream((
1055                                        id.to_string(),
1056                                        ResponseStreamType::Toolcall(lines),
1057                                    ));
1058                                    break;
1059                                } else {
1060                                    Err(chatbot_err!(StreamingError,
1061                                        "No response_id found! This should never happen!"
1062                                    ))?;
1063                                };
1064                            }
1065                            "response.output_text.delta" | "response.refusal.delta" => {
1066                                if let Some(id) = &response_id {
1067                                    yield StreamEvent::ResponseIdStream((
1068                                        id.to_string(),
1069                                        ResponseStreamType::TextResponse(lines),
1070                                    ));
1071                                    break;
1072                                } else {
1073                                    Err(chatbot_err!(StreamingError,
1074                                        "No response_id found! This should never happen!"
1075                                    ))?;
1076                                };
1077                            }
1078                            "response.incomplete" => {
1079                                // put in incomplete reason!
1080                                break Err(chatbot_err!(StreamingError, format!("Response incomplete. Response id: {}", response_id.as_deref().unwrap_or("not received"))))?
1081                            },
1082                            "response.error" | "error" | "response.failed" => { error_incoming = true; }
1083                            _ => {
1084                                if !ALL_EXPECTED_EVENTS.contains(&event_type.as_str()) {
1085                                    warn!("Received unexpected event from Azure: Event: {}", event_type);
1086                                };
1087                            }
1088                        }
1089                        continue;
1090                    }
1091                    Some(ParsedResponseLine::Data(response_output)) => response_output,
1092                    None => {
1093                        continue;
1094                    }
1095                };
1096
1097                if error_incoming {
1098                    let fallback_error = chatbot_err!(StreamingError, format!("Response failed without receiving an API error. Response output: {:?} Response id: {}", &response_output, response_id.as_deref().unwrap_or("not received")));
1099
1100                    if let Some(response) = response_output.response
1101                    && let Some(err) = response.error {
1102                        let mut error = chatbot_err!(
1103                            StreamingError,
1104                            format!("Error received from Azure API. Response id: {}", response_id.as_deref().unwrap_or("not received"))
1105                        );
1106                        error.add_azure_source(err);
1107                        break Err(error)?
1108                    } else if let Some(err) = response_output.error {
1109                        let mut error = chatbot_err!(
1110                            StreamingError,
1111                            format!("Error received from Azure API. Response id: {}", response_id.as_deref().unwrap_or("not received"))
1112                        );
1113                        error.add_azure_source(err);
1114                        break Err(error)?
1115                    } else {
1116                        break Err(fallback_error)?
1117                    };
1118                };
1119                if response_created_incoming {
1120                    let res = response_output.response.ok_or(chatbot_err!(
1121                        DeserializationError,
1122                        "Expected response object"
1123                    ))?;
1124                    response_id = res.id;
1125                    response_created_incoming = false;
1126                }
1127                if output_item_added {
1128                    let item = response_output.item.ok_or(chatbot_err!(
1129                        DeserializationError,
1130                        "Expected response output item"
1131                    ))?;
1132                    yield StreamEvent::Item(StreamItem {item, finished: false});
1133                    output_item_added = false;
1134                }
1135                else if output_item_done {
1136                    let item = response_output.item.ok_or(chatbot_err!(
1137                        DeserializationError,
1138                        "Expected response output item"
1139                    ))?;
1140                    yield StreamEvent::Item(StreamItem {item, finished: true});
1141                    output_item_done = false;
1142                }
1143            }
1144        }
1145        continue;
1146    }
1147    Err(chatbot_err!(StreamingError, format!(
1148        "The response received from Azure ended unexpectedly. Response id: {}", response_id.as_deref().unwrap_or("not received")
1149    )))?
1150    })
1151}
1152
1153/// Streams and parses an LLM response from Azure that contains a text response.
1154/// Consumes the lines (stream) from Azure, because the stream ends when a text response
1155/// is finished.
1156/// Returns a stream to be consumed in the caller.
1157async fn parse_text_response<'a>(
1158    conn: &'a mut PgConnection,
1159    mut lines: PeekableLinesStream<'a>,
1160    full_response_text: Arc<Mutex<Vec<String>>>,
1161    done: Arc<AtomicBool>,
1162    response_message: ChatbotConversationMessage,
1163    request_estimated_tokens: i32,
1164    response_id: String,
1165) -> BoxStream<'a, ChatbotResult<StreamEvent<'a>>> {
1166    trace!("Parsing stream to user...");
1167
1168    let mut response_received = false;
1169
1170    Box::pin(async_stream::try_stream! {
1171        while let Some(val) = lines.next().await {
1172            let line = val?;
1173            let response_output: ResponseOutput = match ParsedResponseLine::parse(&line)? {
1174                Some(ParsedResponseLine::Event(event_type)) => {
1175                    trace!("Event: {event_type}");
1176                    match event_type.as_str() {
1177                        "response.completed" | "response.incomplete" => {response_received = true;},
1178                        "response.output_text.delta" | "response.refusal.delta" => {
1179                            // streaming
1180                        },
1181                        "response.function_call_arguments.delta" | "response.custom_tool_call_input.delta" => {
1182                            error!("ERROR, function call received but can't be processed while streaming to user.");
1183                            return Err(chatbot_err!(StreamingError, "Unexpected function call while streaming to user"))?
1184                        },
1185                        "response.error" | "error" | "response.failed" => {
1186                            // error is logged in the next iteration
1187                        }
1188                        _ => {
1189                            if !ALL_EXPECTED_EVENTS.contains(&event_type.as_str()) {
1190                                warn!("Received unexpected event from Azure: Event: {}", event_type);
1191                            };
1192                        }
1193                    };
1194                    continue;
1195                },
1196                Some(ParsedResponseLine::Data(data)) => *data,
1197                None => {continue;},
1198            };
1199
1200            // Surface any error the API reports (e.g. response.error, response.failed)
1201            // instead of continuing. Normal responses carry no error object.
1202            if let Some(response) = response_output.response
1203            && let Some(err) = response.error {
1204                let mut error = chatbot_err!(
1205                    StreamingError,
1206                    format!("Error received from Azure API. Response id: {}", &response_id)
1207                );
1208                error.add_azure_source(err);
1209                Err(error)?
1210            // Surface the error in case there is no response object, just an error
1211            } else if let Some(err) = response_output.error {
1212                let mut error = chatbot_err!(
1213                    StreamingError,
1214                    format!("Error received from Azure API. Response id: {}", &response_id)
1215                );
1216                error.add_azure_source(err);
1217                Err(error)?
1218            };
1219
1220            let mut full_response_text = full_response_text.lock().await;
1221
1222            if response_received {
1223                if let Some(response) = &response_output.incomplete_response {
1224                // todo: can add content filter results for more info
1225                Err(chatbot_err!(StreamingError,
1226                    format!("The LLM response is incomplete. Reason: {}", response.incomplete_details.reason)
1227                ))?
1228            };
1229                let full_response_as_string = full_response_text.join("");
1230                // todo: use the tokens given in the response
1231                let estimated_cost = estimate_tokens(&full_response_as_string);
1232                trace!(
1233                    "End of chatbot response stream. Estimated cost: {}. Response: {}",
1234                    estimated_cost, full_response_as_string
1235                );
1236                models::chatbot_conversation_messages::update(
1237                    conn,
1238                    response_message.id,
1239                    &full_response_as_string,
1240                    true,
1241                    request_estimated_tokens + estimated_cost,
1242                ).await?;
1243
1244                done.store(true, atomic::Ordering::Relaxed);
1245                yield StreamEvent::Done;
1246                break;
1247            }
1248
1249            if let Some(delta) = &response_output.delta {
1250                full_response_text.push(delta.to_owned());
1251                yield StreamEvent::Delta(delta.clone());
1252            }
1253
1254            if let Some(item) = &response_output.item {
1255                match item {
1256                    OutputItem::Message { .. } => continue,
1257                    OutputItem::FunctionCall { .. } => Err(chatbot_err!(StreamingError, "Error: unexpected function call after / during a text response.".to_string()))?,
1258                    _ => {
1259                        let finished = response_output.response_type.as_deref() == Some("response.output_item.done");
1260                        yield StreamEvent::Item(StreamItem { item: item.to_owned(), finished });
1261                        continue;
1262                    },
1263                };
1264            }
1265        }
1266        if !done.load(atomic::Ordering::Relaxed) {
1267            Err(chatbot_err!(StreamingError,"Stream ended unexpectedly"))?;
1268        }
1269    })
1270}
1271
1272/// For passing streamed events and data between streaming functions.
1273enum StreamEvent<'a> {
1274    Delta(String),
1275    Refusal(String),
1276    Item(StreamItem),
1277    Messages(Vec<APIInputMessage>),
1278    ResponseIdStream((String, ResponseStreamType<'a>)),
1279    Done,
1280}
1281
1282#[derive(Deserialize, Serialize, Debug, Clone)]
1283struct StreamItem {
1284    /// Item received from Azure.
1285    item: OutputItem,
1286    /// Has the item, like tool call or reasoning, been completed or is it in progress. When OutputItem is FunctionCallOutput, this field is ignored.
1287    finished: bool,
1288}
1289
1290/// Makes a request to Azure and returns the resulting stream.
1291pub async fn make_request_and_create_stream<'a>(
1292    chat_request: LLMRequest,
1293    app_config: &ApplicationConfiguration,
1294) -> ChatbotResult<PeekableLinesStream<'a>> {
1295    let response = make_streaming_llm_request(chat_request, app_config).await?;
1296
1297    trace!("Receiving chat response with {:?}", response.version());
1298
1299    if !response.status().is_success() {
1300        let status = response.status();
1301        let error_message = response.text().await?;
1302        return Err(chatbot_err!(
1303            StreamingError,
1304            format!(
1305                "Failed to send chat request. Status: {}. Error: {}",
1306                status, error_message
1307            )
1308        ));
1309    }
1310
1311    let stream = response
1312        .bytes_stream()
1313        .map_err(std::io::Error::other)
1314        .boxed();
1315    let reader = StreamReader::new(stream);
1316    let lines = reader.lines();
1317    let lines_stream = LinesStream::new(lines);
1318    let peekable_lines_stream = lines_stream.peekable();
1319    let pinned_lines = Box::pin(peekable_lines_stream);
1320
1321    Ok(pinned_lines)
1322}
1323
1324/// Creates a ChatbotChatStreamEvent::Error from the message and returns it in string form.
1325/// Either message or error should be Some.
1326/// If message is Some, use it as the StreamEvent message. Else, use ChatbotError's message.
1327/// error is either a ResponseError received from Azure and stored in ChatbotError, or
1328/// ChatbotError's error message, or None.
1329fn error_event_string_from_message(
1330    message: Option<&str>,
1331    error: Option<&ChatbotError>,
1332) -> ChatbotResult<String> {
1333    let (message, details): (&str, Option<String>) = if let Some(e) = error {
1334        let e_msg = if let Some(s) = e.azure_source() {
1335            format!("{s}")
1336        } else {
1337            e.message().to_string()
1338        };
1339        (message.unwrap_or(e.message()), Some(e_msg))
1340    } else {
1341        (
1342            message.ok_or(chatbot_err!(
1343                Other,
1344                "Called error_event_string_from_message with incorrect arguments"
1345            ))?,
1346            None,
1347        )
1348    };
1349    let err = ChatbotChatStreamEvent::Error(StreamEventError {
1350        message: message.to_string(),
1351        details,
1352    });
1353    serde_json::to_string(&err).map_err(ChatbotError::from)
1354}
1355
1356/// These types of ChatbotErrors shouldn't be shown to the user and are likely created
1357/// from an unrecoverable error in our code that should make the stream fail.
1358fn check_error_should_terminate_stream(err: &ChatbotErrorType) -> bool {
1359    matches!(
1360        err,
1361        ChatbotErrorType::SerdeJson
1362            | ChatbotErrorType::DeserializationError
1363            | ChatbotErrorType::SqlxError
1364            | ChatbotErrorType::ReqwestError
1365            | ChatbotErrorType::UrlParse
1366    )
1367}
1368
1369async fn answer_unfinished_tool_calls(
1370    conn: &mut PgConnection,
1371    conversation_id: Uuid,
1372) -> ChatbotResult<()> {
1373    trace!(
1374        "Dealing with unfinished tool calls for conversation {}",
1375        conversation_id
1376    );
1377    let res = headless_lms_models::chatbot_conversation_messages::answer_hanging_tool_call_messages_for_conversation(
1378        conn,
1379        conversation_id,
1380    )
1381    .await
1382    .map_err(ChatbotError::from)?;
1383    trace!("Answered {} hanging tool calls", res.len());
1384    Ok(())
1385}
1386
1387/// Send and parse a Chatbot message and response and stream it to the user.
1388/// Controls the whole operation.
1389pub async fn send_chat_request_and_parse_stream(
1390    pool: PgPool,
1391    app_configuration: &ApplicationConfiguration,
1392    chatbot_configuration_id: Uuid,
1393    conversation_id: Uuid,
1394    message: &str,
1395    user_context: ChatbotUserContext,
1396) -> ChatbotResult<Pin<Box<dyn Stream<Item = ChatbotResult<Bytes>> + Send>>> {
1397    let mut conn = pool.acquire().await?;
1398    let app_config = app_configuration.to_owned();
1399    let (mut chat_request, request_estimated_tokens) =
1400        LLMRequest::build_and_insert_incoming_user_message_to_db(
1401            &mut conn,
1402            chatbot_configuration_id,
1403            conversation_id,
1404            message,
1405            &app_config,
1406        )
1407        .await?;
1408
1409    let mut max_iterations_left = 15;
1410
1411    // response id created by Azure, can be used in figuring out what went wrong if
1412    // request or streaming fails. also stored in the db.
1413    let response_id = Arc::new(Mutex::new(String::new()));
1414
1415    let done = Arc::new(AtomicBool::new(false));
1416    let mut should_clean_tool_calls = false;
1417    let full_response_text = Arc::new(Mutex::new(Vec::new()));
1418    let response_message_id = Arc::new(Mutex::new(Uuid::nil()));
1419
1420    // Instantiate the guard before creating the stream.
1421    let guard = RequestCancelledGuard {
1422        response_message_id: response_message_id.clone(),
1423        received_string: full_response_text.clone(),
1424        pool: pool.clone(),
1425        done: done.clone(),
1426        request_estimated_tokens,
1427    };
1428
1429    let response_stream = async_stream::try_stream! {
1430        'outer: loop {
1431            let mut conn = pool.acquire().await?;
1432
1433            max_iterations_left -= 1;
1434            if max_iterations_left == 0 {
1435                error!("Maximum tool call iterations exceeded");
1436                let event_string = error_event_string_from_message(Some("Maximum tool call iterations exceeded. The LLM may be stuck in a loop."), None)?;
1437                yield Bytes::from(event_string);
1438                yield Bytes::from("\n");
1439                done.store(true, atomic::Ordering::Relaxed);
1440                break 'outer;
1441            }
1442
1443            let lines = match make_request_and_create_stream(chat_request.clone(), &app_config).await {
1444                Ok(val) => val,
1445                Err(error) => {
1446                    if check_error_should_terminate_stream(error.error_type()) {
1447                        break Err(error)?;
1448                    };
1449                    let event_string = error_event_string_from_message(None, Some(&error))?;
1450                    yield Bytes::from(event_string);
1451                    yield Bytes::from("\n");
1452                    done.store(true, atomic::Ordering::Relaxed);
1453                    break 'outer;
1454                },
1455            };
1456            let mut response_stream = stream_and_detect_response_stream_type(lines);
1457            let (received_response_id, typed_response_stream);
1458            loop {
1459                if let Some(val) = response_stream.next().await {
1460                match val {
1461                    Ok(StreamEvent::ResponseIdStream(stuff)) => {
1462                        (received_response_id, typed_response_stream) = stuff;
1463                        break;
1464                    },
1465                    Ok(StreamEvent::Item(item)) => {
1466                        if item.finished {
1467                            // save it to db and put it in the LLM Request input
1468                            // in case another request will be made. Reuse the iteration's `conn`
1469                            // (still free here) instead of acquiring a second pool connection.
1470                            let message = process_output_item(&mut conn, item.item.to_owned(), conversation_id, &app_config).await?;
1471                            let input_message = APIInputMessage::try_from(message)?;
1472                            chat_request.input.push(input_message);
1473
1474                        }
1475                        let event = ChatbotChatStreamEvent::from(item.to_owned());
1476                        if event != ChatbotChatStreamEvent::Invalid {
1477                            let event_string = serde_json::to_string(&event)?;
1478                            yield Bytes::from(event_string);
1479                            yield Bytes::from("\n");
1480                        };
1481                    },
1482                    Ok(StreamEvent::Refusal(text)) => {
1483                        // in practice this event shoudln't happen because when a refusal
1484                        // is being streamed, its streaming is done by parse_text_response.
1485                        error!("Chatbot refusal event encountered before response id was received.");
1486                        let message_id = *response_message_id.lock().await;
1487                        let event = ChatbotChatStreamEvent::Delta { text, message_id };
1488                        let event_string = serde_json::to_string(&event)?;
1489                        yield Bytes::from(event_string);
1490                        yield Bytes::from("\n");
1491
1492                    },
1493                    Ok(StreamEvent::Done) => {
1494                        done.store(true, atomic::Ordering::Relaxed);
1495                        Err(chatbot_err!(StreamingError, "Stream ended unxpectedly."))?
1496                    },
1497                    Ok(StreamEvent::Messages(_)) | Ok(StreamEvent::Delta(_)) => {
1498                        done.store(true, atomic::Ordering::Relaxed);
1499                        Err(chatbot_err!(StreamingError, "This shouldn't happen, messages or response delta not expected."))?
1500                    },
1501                    Err(e) => {
1502                        error!("Stream ended unexpectedly. Response id: {} Error: {}", response_id.lock().await, e);
1503                        should_clean_tool_calls = true;
1504                        if check_error_should_terminate_stream(e.error_type()) {
1505                            if let Err(e2) = answer_unfinished_tool_calls(&mut conn, conversation_id).await {
1506                                error!("Error in chatbot streaming and couldn't answer unfinished tool calls: {e2}. Response id: {}", response_id.lock().await);
1507                            };
1508                            return Err(e)?;
1509                        };
1510                        let event_string = error_event_string_from_message(None, Some(&e))?;
1511                        yield Bytes::from(event_string);
1512                        yield Bytes::from("\n");
1513                        done.store(true, atomic::Ordering::Relaxed);
1514                        break 'outer;
1515                    },
1516                }}
1517            }
1518
1519            {
1520                // update response_id once it's found.
1521                let mut response_id = response_id.lock().await;
1522                *response_id = received_response_id;
1523            }
1524
1525            // create unitialized response message in this scope
1526            let response_message: ChatbotConversationMessage;
1527
1528            let mut final_stream = match typed_response_stream {
1529                ResponseStreamType::Toolcall(stream) => {
1530                    parse_tool(&mut conn, stream, conversation_id, &user_context).await
1531                }
1532                ResponseStreamType::TextResponse(stream) => {
1533                    let response_id = response_id.lock().await;
1534                    // create response_message once we need to start streaming to user.
1535                    response_message = models::chatbot_conversation_messages::insert(
1536                        &mut conn,
1537                        ChatbotConversationMessage {
1538                            conversation_id,
1539                            message: Message::Text(ChatbotConversationMessageMessage {
1540                                text: "".to_string(),
1541                                message_role: MessageRole::Assistant,
1542                                message_is_complete: false,
1543                                used_tokens: request_estimated_tokens,
1544                                response_id: Some(response_id.to_owned()),
1545                                ..Default::default()
1546                            }),
1547                            ..Default::default()
1548                        },
1549                    ).await?;
1550
1551                    // set the correct response_message_id
1552                    let mut response_message_id = response_message_id.lock().await;
1553                    *response_message_id = response_message.id;
1554
1555                    // update citation ids. then, stream the response in parse_text_response.
1556                    models::chatbot_conversation_messages_citations::update_citation_message_ids(
1557                        &mut conn,
1558                        response_id.to_string(),
1559                        response_message.id,
1560                    ).await?;
1561
1562                    parse_text_response(&mut conn, stream, full_response_text.clone(), done.clone(), response_message, request_estimated_tokens, response_id.to_string()).await
1563                }
1564            };
1565
1566            let message_id = *response_message_id.lock().await;
1567            let response_id = response_id.lock().await;
1568            while let Some(line) = final_stream.next().await {
1569                let val = match line {
1570                    Ok(val) => val,
1571                    Err(e) => {
1572                        error!("Stream ended unexpectedly. Response id: {} Error: {}", response_id.to_string(), e);
1573                        let full_response_as_string = full_response_text.lock().await.join("");
1574                        let mut conn = pool.acquire().await?;
1575                        if !full_response_as_string.is_empty() {
1576                            // save the incomplete response received
1577                            let estimated_cost = estimate_tokens(&full_response_as_string);
1578                            models::chatbot_conversation_messages::update(
1579                                &mut conn,
1580                                message_id,
1581                                &full_response_as_string,
1582                                true,
1583                                request_estimated_tokens + estimated_cost,
1584                            ).await?;
1585                        };
1586                        should_clean_tool_calls = true;
1587                        if check_error_should_terminate_stream(e.error_type()) {
1588                            if let Err(e2) = answer_unfinished_tool_calls(&mut conn, conversation_id).await {
1589                                error!("Error in chatbot streaming and couldn't answer unfinished tool calls: {e2}. Response id: {}", response_id.to_string());
1590                            };
1591                            return Err(e)?;
1592                        };
1593                        let event_string = error_event_string_from_message(None, Some(&e))?;
1594                        yield Bytes::from(event_string);
1595                        yield Bytes::from("\n");
1596                        done.store(true, atomic::Ordering::Relaxed);
1597                        break 'outer;
1598                    }
1599                };
1600                match val {
1601                    StreamEvent::Delta(text) | StreamEvent::Refusal(text) => {
1602                        let delta = ChatbotChatStreamEvent::Delta { text, message_id };
1603                        let delta_as_string = serde_json::to_string(&delta)?;
1604                        yield Bytes::from(delta_as_string);
1605                        yield Bytes::from("\n");
1606                    },
1607                    StreamEvent::Item(stream_item) => {
1608                        match stream_item.item  {
1609                            OutputItem::FunctionCall { .. } | OutputItem::FunctionCallOutput { .. } => {
1610                                // item already processed
1611                            },
1612                            _ => {
1613                                // save this item in the db if it's finished
1614                                if stream_item.finished {
1615                                    let mut conn = pool.acquire().await?;
1616                                    process_output_item(&mut conn, stream_item.item.to_owned(), conversation_id, &app_config).await?;
1617                                }
1618                            },
1619                        };
1620
1621                        let response = ChatbotChatStreamEvent::from(stream_item);
1622                        if response != ChatbotChatStreamEvent::Invalid {
1623                            let event_string = serde_json::to_string(&response)?;
1624                            yield Bytes::from(event_string);
1625                            yield Bytes::from("\n");
1626                        };
1627                    },
1628                    StreamEvent::Messages(messages) => {
1629                        chat_request.input.extend(messages);
1630                    },
1631                    StreamEvent::Done => {
1632                        let event =  ChatbotChatStreamEvent::Done;
1633                        let event_string = serde_json::to_string(&event)?;
1634                        yield Bytes::from(event_string);
1635                        yield Bytes::from("\n");
1636                        break 'outer;
1637                    }
1638                    StreamEvent::ResponseIdStream(..) => {
1639                        done.store(true, atomic::Ordering::Relaxed);
1640                        Err(chatbot_err!(StreamingError, "This shouldn't happen, response stream received while already streaming a response stream to user."))?
1641                    },
1642                }
1643            }
1644        }
1645        if should_clean_tool_calls { answer_unfinished_tool_calls(&mut conn, conversation_id).await?;}
1646
1647        if !done.load(atomic::Ordering::Relaxed) {
1648            let id = response_id.lock().await;
1649            let event_string = error_event_string_from_message(Some(format!("Stream ended unexpectedly. Response id: {id}").as_str()), None)?;
1650            yield Bytes::from(event_string);
1651            yield Bytes::from("\n");
1652        }
1653    };
1654
1655    // Encapsulate the stream and the guard within GuardedStream. This moves the request guard into the stream and ensures that it is dropped when the stream is dropped.
1656    // This way we do cleanup only when the stream is dropped and not when this function returns.
1657    let guarded_stream = GuardedStream::new(guard, response_stream);
1658
1659    // Box and pin the GuardedStream to satisfy the Unpin requirement
1660    Ok(Box::pin(guarded_stream))
1661}
1662
1663#[cfg(test)]
1664mod tests {
1665    use super::*;
1666
1667    /// A `response.failed` line carries `error` as an object, not a string. Deserializing it
1668    /// must succeed so the error can be surfaced instead of crashing the stream parser.
1669    #[test]
1670    fn response_failed_with_error_object_deserializes() {
1671        let line = r#"{"type":"response.failed","response":{"id":"resp_abc","status":"failed","error":{"code":"tool_user_error","message":"Could not complete vectorization action."}},"sequence_number":8}"#;
1672
1673        let parsed: ResponseOutput = serde_json::from_str(line).unwrap();
1674        let error = parsed
1675            .response
1676            .expect("response object")
1677            .error
1678            .expect("error object");
1679
1680        assert_eq!(error.code.as_deref(), Some("tool_user_error"));
1681        assert!(error.message.unwrap().contains("vectorization"));
1682    }
1683
1684    /// Azure returns azure_ai_search call/output items with `arguments`/`output` as strings.
1685    #[test]
1686    fn azure_ai_search_output_items_deserialize() {
1687        let call = r#"{"type":"azure_ai_search_call","id":"fc_1","response_id":"resp_abc","call_id":"call_1","arguments":"{\"query\":\"trademarks\"}","status":"completed"}"#;
1688        match serde_json::from_str::<OutputItem>(call).unwrap() {
1689            OutputItem::AzureAiSearchCall { arguments, .. } => {
1690                assert!(arguments.contains("trademarks"))
1691            }
1692            other => panic!("expected AzureAiSearchCall, got {other:?}"),
1693        }
1694
1695        let output = r#"{"type":"azure_ai_search_call_output","id":"fco_1","response_id":"resp_abc","call_id":"call_1","output":"remote tool call failed","status":"in_progress"}"#;
1696        match serde_json::from_str::<OutputItem>(output).unwrap() {
1697            OutputItem::AzureAiSearchCallOutput { output, .. } => {
1698                assert_eq!(output, "remote tool call failed")
1699            }
1700            other => panic!("expected AzureAiSearchCallOutput, got {other:?}"),
1701        }
1702    }
1703}