Skip to main content

headless_lms_chatbot/azure_chatbot/
request.rs

1//! Building the request one turn sends: the conversation as Azure should replay it, plus the
2//! system prompt and the tools the caller may use.
3
4use headless_lms_base::config::ApplicationConfiguration;
5use headless_lms_models::chatbot_configurations::ChatbotConfiguration;
6use headless_lms_models::chatbot_configurations_models::{ChatbotConfigurationModel, ModelType};
7use headless_lms_models::chatbot_conversation_message_messages::MessageRole;
8use headless_lms_models::chatbot_conversation_message_reasoning::ChatbotConversationMessageReasoning;
9use headless_lms_models::chatbot_conversation_messages::{ChatbotConversationMessage, Message};
10
11use super::azure::protocol::{
12    InputItem, LLMRequest, LLMRequestParams, LLMToolChoice, RequestTextOptions,
13};
14use super::azure::tools::AzureLLMToolDefinition;
15use super::client_tool_calls::abort::ToolCallAbortReason;
16use super::search_grounding::build_search_grounding_instruction;
17use crate::chatbot_error::ChatbotResult;
18use crate::chatbot_tools::provider_tools::azure_ai_search::{
19    self, get_azure_ai_search_tool_definition,
20};
21use crate::chatbot_tools::tool_category::EnabledToolCategories;
22use crate::chatbot_tools::{
23    get_client_chatbot_tool_definitions, get_permitted_chatbot_tool_definitions,
24};
25use crate::conversation_context::{
26    ChatbotPageContext, insert_page_context_message_if_changed, resolve_page_context,
27};
28use crate::llm_utils::{APIInputMessage, MessageContent, estimate_tokens, get_params_for_model};
29use crate::prelude::*;
30use crate::user_context::ChatbotTurnContext;
31
32/// A conversation's stored messages as the request should replay them.
33///
34/// Replaying reasoning keeps a turn's prefix matching what the previous turn's later rounds sent,
35/// so only reasoning is ever dropped, and only where Azure would reject it: without an
36/// `encrypted_content` payload it cannot be resolved once responses are not stored Azure-side, and
37/// unless the item it reasoned about follows it immediately the request 400s. A conversation whose
38/// stored tail is a reasoning item — a stream that died mid-round, or a round that persisted its
39/// reasoning before its calls — would otherwise be unusable for good.
40fn replayable_input_messages(
41    messages: Vec<ChatbotConversationMessage>,
42) -> ChatbotResult<Vec<APIInputMessage>> {
43    let mut kept: Vec<ChatbotConversationMessage> = Vec::with_capacity(messages.len());
44    // Backwards, because whether a reasoning item may stay depends on what survives after it.
45    for message in messages.into_iter().rev() {
46        let keep = match &message.message {
47            Message::Reasoning(reasoning) => {
48                reasoning.encrypted_content.is_some()
49                    && kept
50                        .last()
51                        .is_some_and(|next| may_follow_reasoning(reasoning, next))
52            }
53            _ => true,
54        };
55        if keep {
56            kept.push(message);
57        }
58    }
59    kept.into_iter()
60        .rev()
61        .map(APIInputMessage::try_from)
62        .collect()
63}
64
65/// One message a round just stored, as the next round should carry it, or `None` for a reasoning
66/// item Azure would reject: without an `encrypted_content` payload it cannot be resolved once
67/// responses are not stored Azure-side.
68///
69/// The in-turn counterpart of [`replayable_input_messages`], which applies the same rule plus the
70/// ordering one that only a stored conversation can be checked against.
71pub(super) fn replayable_input_message(
72    message: ChatbotConversationMessage,
73) -> ChatbotResult<Option<APIInputMessage>> {
74    if let Message::Reasoning(reasoning) = &message.message
75        && reasoning.encrypted_content.is_none()
76    {
77        return Ok(None);
78    }
79    APIInputMessage::try_from(message).map(Some)
80}
81
82/// Whether Azure accepts `next` immediately after the reasoning item `reasoning`.
83///
84/// Normally that is the item the reasoning reasoned about. The exception is another reasoning item
85/// of the same response: a round that reasons more than once emits its items in a run, so replaying
86/// the run whole is what keeps the next turn's prefix matching what that round itself sent.
87fn may_follow_reasoning(
88    reasoning: &ChatbotConversationMessageReasoning,
89    next: &ChatbotConversationMessage,
90) -> bool {
91    match &next.message {
92        Message::ToolCall(_) => true,
93        Message::Text(text) => text.message_role == MessageRole::Assistant,
94        Message::Reasoning(later) => later.response_id == reasoning.response_id,
95        Message::ToolOutput(_) => false,
96    }
97}
98
99/// Routes every request of one conversation to the same prompt cache, or `None` for a model that has
100/// no Azure prompt cache.
101///
102/// The transcript is append-only, so a turn's prefix contains the previous turn's, and the entry
103/// worth routing to is the one that turn wrote.
104fn conversation_prompt_cache_key(model_type: &ModelType, conversation_id: Uuid) -> Option<String> {
105    model_type
106        .is_azure_openai()
107        .then(|| conversation_id.to_string())
108}
109
110impl LLMRequest {
111    /// A request with no tools, tool choice, cache key, or output cap — the shape every one-shot
112    /// LLM call outside a chatbot turn starts from. Override fields via struct-update syntax.
113    pub fn new(model: String, input: Vec<APIInputMessage>, params: LLMRequestParams) -> Self {
114        Self {
115            input,
116            model,
117            tools: Vec::new(),
118            tool_choice: None,
119            parallel_tool_calls: None,
120            max_output_tokens: None,
121            text: None,
122            prompt_cache_key: None,
123            params,
124        }
125    }
126
127    /// Writes the learner's new message to the conversation and builds the request for the turn
128    /// that answers it.
129    ///
130    /// The write happens in one transaction with `abort_pending_client_tool_calls`, so a client
131    /// answering a suspended tool call at the same moment either gets its answer in before the
132    /// learner moved on, or finds the call already aborted. `page_context` is recorded ahead of
133    /// the message it gives context for.
134    pub(super) async fn build_and_insert_incoming_user_message_to_db(
135        conn: &mut PgConnection,
136        chatbot_configuration_id: Uuid,
137        conversation_id: Uuid,
138        message: &str,
139        page_context: Option<ChatbotPageContext>,
140        user_context: &ChatbotTurnContext,
141        app_config: &ApplicationConfiguration,
142    ) -> ChatbotResult<Self> {
143        let configuration =
144            models::chatbot_configurations::get_by_id(conn, chatbot_configuration_id).await?;
145
146        let page = match page_context {
147            Some(page_context) => {
148                resolve_page_context(conn, page_context, configuration.course_id).await
149            }
150            None => None,
151        };
152
153        let mut tx = conn.begin().await?;
154
155        models::chatbot_conversation_messages::abort_pending_client_tool_calls(
156            &mut tx,
157            conversation_id,
158            ToolCallAbortReason::Replaced.model_output(),
159        )
160        .await?;
161
162        if let Some(page) = &page {
163            // Runs under the lock the abort above took, which is what keeps two requests from both
164            // deciding the context changed and both writing it.
165            insert_page_context_message_if_changed(
166                &mut tx,
167                conversation_id,
168                page,
169                user_context.course_name.as_deref(),
170            )
171            .await?;
172        }
173
174        models::chatbot_conversation_messages::insert(
175            &mut tx,
176            ChatbotConversationMessage::text(
177                conversation_id,
178                MessageRole::User,
179                message.to_string(),
180                estimate_tokens(message),
181                None,
182            ),
183        )
184        .await?;
185
186        tx.commit().await?;
187
188        Self::build_from_conversation(
189            conn,
190            &configuration,
191            conversation_id,
192            user_context,
193            app_config,
194        )
195        .await
196    }
197
198    /// Builds the request for a turn from the conversation exactly as it is stored, writing
199    /// nothing.
200    ///
201    /// Both the turn that follows a new user message and a resumed turn go through here, a
202    /// resumed one adding nothing of its own beyond the tool output that woke it. The tools the
203    /// request offers depend on `user_context`, so a caller who has lost a role is not offered a
204    /// tool that needs it again.
205    pub(super) async fn build_from_conversation(
206        conn: &mut PgConnection,
207        configuration: &ChatbotConfiguration,
208        conversation_id: Uuid,
209        user_context: &ChatbotTurnContext,
210        app_config: &ApplicationConfiguration,
211    ) -> ChatbotResult<Self> {
212        let inputs = TurnInputs::load(conn, configuration, conversation_id, user_context).await?;
213        Self::assemble(
214            configuration,
215            conversation_id,
216            inputs,
217            app_config,
218            &user_context.enabled_tool_categories,
219        )
220    }
221
222    /// Assembles the request's shape — grounding instruction, tool list, `tool_choice`, params,
223    /// prompt cache key, and the system-message prepend — from data [`TurnInputs::load`] already
224    /// read from the database.
225    fn assemble(
226        configuration: &ChatbotConfiguration,
227        conversation_id: Uuid,
228        inputs: TurnInputs,
229        app_config: &ApplicationConfiguration,
230        enabled_tool_categories: &EnabledToolCategories,
231    ) -> ChatbotResult<Self> {
232        let TurnInputs {
233            model,
234            messages,
235            mut tools,
236        } = inputs;
237
238        let offers_tools = !tools.is_empty();
239        let offers_search = configuration.use_azure_search
240            && enabled_tool_categories.contains(azure_ai_search::CATEGORY);
241
242        let mut system_prompt = configuration.prompt.clone();
243        system_prompt.push_str(
244            "All code you generate should be indented with 2 spaces, regardless of the language.\n",
245        );
246        if offers_search {
247            system_prompt.push_str(&build_search_grounding_instruction(enabled_tool_categories));
248            tools.push(AzureLLMToolDefinition::Search(
249                get_azure_ai_search_tool_definition(
250                    app_config,
251                    configuration.course_id.ok_or_else(|| {
252                        chatbot_err!(Other, "Course id is missing from the chatbot configuration")
253                    })?,
254                    configuration.use_semantic_reranking,
255                )?,
256            ));
257        }
258
259        let tool_choice = if offers_tools || offers_search {
260            Some(LLMToolChoice::Auto)
261        } else {
262            None
263        };
264
265        let params = get_params_for_model(&model.model, &model.model_type, Some(configuration));
266        let prompt_cache_key = conversation_prompt_cache_key(&model.model_type, conversation_id);
267
268        let mut api_chat_messages = replayable_input_messages(messages)?;
269        api_chat_messages.insert(
270            0,
271            APIInputMessage {
272                message_type: InputItem::Message {
273                    role: MessageRole::System,
274                    content: MessageContent::Text(system_prompt),
275                },
276            },
277        );
278
279        Ok(Self {
280            input: api_chat_messages,
281            model: model.model,
282            max_output_tokens: Some(configuration.max_output_tokens),
283            tools,
284            tool_choice,
285            parallel_tool_calls: Some(true),
286            text: Some(RequestTextOptions {
287                verbosity: Some(configuration.verbosity),
288                format: None,
289            }),
290            prompt_cache_key,
291            params,
292        })
293    }
294}
295
296/// The database reads a turn's request is assembled from: the configured model, the
297/// conversation's stored messages, and the tools `user_context` is currently permitted to use.
298struct TurnInputs {
299    model: ChatbotConfigurationModel,
300    messages: Vec<ChatbotConversationMessage>,
301    tools: Vec<AzureLLMToolDefinition>,
302}
303
304impl TurnInputs {
305    async fn load(
306        conn: &mut PgConnection,
307        configuration: &ChatbotConfiguration,
308        conversation_id: Uuid,
309        user_context: &ChatbotTurnContext,
310    ) -> ChatbotResult<Self> {
311        let model = models::chatbot_configurations_models::get_by_chatbot_configuration_id(
312            conn,
313            configuration.id,
314        )
315        .await?;
316
317        let messages =
318            models::chatbot_conversation_messages::get_by_conversation_id(conn, conversation_id)
319                .await?;
320
321        let mut tools = get_permitted_chatbot_tool_definitions(conn, user_context).await?;
322        tools.extend(get_client_chatbot_tool_definitions(conn, user_context).await?);
323
324        Ok(Self {
325            model,
326            messages,
327            tools,
328        })
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use headless_lms_models::{
335        chatbot_conversation_message_tool_calls::ToolKind,
336        insert_data,
337        test_helper::{
338            Conn, chatbot_reasoning_message, chatbot_text_message, chatbot_tool_call_message,
339            chatbot_tool_output_message, insert_chatbot_conversation,
340        },
341    };
342
343    use super::*;
344    use crate::azure_chatbot::test_helpers::shape;
345
346    /// Stores one conversation's worth of messages and gives back the messages they replay as.
347    async fn replayed_items(
348        conn: &mut PgConnection,
349        build: impl Fn(Uuid) -> Vec<ChatbotConversationMessage>,
350    ) -> Vec<APIInputMessage> {
351        let (_configuration, conversation_id) = insert_chatbot_conversation(conn).await;
352        for message in build(conversation_id) {
353            models::chatbot_conversation_messages::insert(conn, message)
354                .await
355                .expect("the message is stored");
356        }
357
358        let stored =
359            models::chatbot_conversation_messages::get_by_conversation_id(conn, conversation_id)
360                .await
361                .expect("the conversation is read back");
362        replayable_input_messages(stored).expect("the messages convert")
363    }
364
365    async fn replayed_shape(
366        conn: &mut PgConnection,
367        build: impl Fn(Uuid) -> Vec<ChatbotConversationMessage>,
368    ) -> Vec<String> {
369        shape(&replayed_items(conn, build).await)
370    }
371
372    /// Reasoning is replayed so the next turn's prefix still matches what the last turn sent, but
373    /// an item from before responses stopped being stored Azure-side has no payload to resolve and
374    /// would fail the whole request. Dropping one must also leave every call behind its own
375    /// reasoning, because Azure rejects a reasoning item that its call does not follow.
376    #[tokio::test]
377    async fn only_reasoning_that_carries_its_payload_is_replayed() {
378        insert_data!(:tx);
379
380        assert_eq!(
381            replayed_shape(tx.as_mut(), |id| vec![
382                chatbot_reasoning_message(id, "rs_replayable", "resp_1", Some("payload")),
383                chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
384                chatbot_tool_output_message(id, "call_1", ToolKind::Function, "resp_1"),
385                chatbot_reasoning_message(id, "rs_legacy", "resp_1", None),
386                chatbot_tool_call_message(id, "call_2", ToolKind::Function, "resp_1"),
387                chatbot_tool_output_message(id, "call_2", ToolKind::Function, "resp_1"),
388            ])
389            .await,
390            vec![
391                "reasoning:rs_replayable",
392                "call:call_1",
393                "output:call_1",
394                "call:call_2",
395                "output:call_2",
396            ]
397        );
398    }
399
400    /// The payload is the whole reason the item is worth replaying, and it reaches Azure only if it
401    /// survives both the write and the read.
402    #[tokio::test]
403    async fn a_replayed_reasoning_item_still_carries_its_payload() {
404        insert_data!(:tx);
405
406        let replayed = replayed_items(tx.as_mut(), |id| {
407            vec![
408                chatbot_reasoning_message(id, "rs_1", "resp_1", Some("opaque-payload")),
409                chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
410            ]
411        })
412        .await;
413
414        let InputItem::Reasoning {
415            encrypted_content, ..
416        } = &replayed
417            .first()
418            .expect("the reasoning item is replayed")
419            .message_type
420        else {
421            panic!("the replayed item is a reasoning item");
422        };
423        assert_eq!(encrypted_content.as_deref(), Some("opaque-payload"));
424    }
425
426    /// Azure 400s a request whose reasoning item is not immediately followed by the item it
427    /// reasoned about, and nothing repairs the conversation afterwards: a stream that dies while
428    /// reasoning — a closed tab, or `max_output_tokens` exhausted — leaves a stored tail that
429    /// every later turn would resend, so the conversation stays dead. Dropping reasoning costs
430    /// only cache hits, so a mispaired item never survives.
431    #[tokio::test]
432    async fn reasoning_is_replayed_only_when_what_it_reasoned_about_follows_it() {
433        insert_data!(:tx);
434
435        assert_eq!(
436            replayed_shape(tx.as_mut(), |id| vec![
437                chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
438                chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
439            ])
440            .await,
441            vec!["reasoning:rs_1", "call:call_1"],
442        );
443
444        assert_eq!(
445            replayed_shape(tx.as_mut(), |id| vec![
446                chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
447                chatbot_text_message(id, MessageRole::Assistant, "Here you go.", Some("resp_1")),
448            ])
449            .await,
450            vec!["reasoning:rs_1", "message:Assistant"],
451        );
452
453        assert_eq!(
454            replayed_shape(tx.as_mut(), |id| vec![
455                chatbot_text_message(id, MessageRole::User, "How do loops work?", None),
456                chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
457            ])
458            .await,
459            vec!["message:User"],
460        );
461
462        assert_eq!(
463            replayed_shape(tx.as_mut(), |id| vec![
464                chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
465                chatbot_text_message(id, MessageRole::User, "How do loops work?", None),
466            ])
467            .await,
468            vec!["message:User"],
469        );
470
471        assert_eq!(
472            replayed_shape(tx.as_mut(), |id| vec![
473                chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
474                chatbot_tool_output_message(id, "call_1", ToolKind::Function, "resp_1"),
475            ])
476            .await,
477            vec!["output:call_1"],
478        );
479
480        // A payload-less item does not count as the follower that keeps the one before it.
481        assert_eq!(
482            replayed_shape(tx.as_mut(), |id| vec![
483                chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
484                chatbot_reasoning_message(id, "rs_legacy", "resp_1", None),
485                chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
486            ])
487            .await,
488            vec!["reasoning:rs_1", "call:call_1"],
489        );
490
491        // Two responses' reasoning ends up adjacent only if what the first reasoned about is gone.
492        assert_eq!(
493            replayed_shape(tx.as_mut(), |id| vec![
494                chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
495                chatbot_reasoning_message(id, "rs_2", "resp_2", Some("payload")),
496                chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
497            ])
498            .await,
499            vec!["reasoning:rs_2", "call:call_1"],
500        );
501    }
502
503    /// A round that reasons more than once emits its reasoning items in a run, so the round itself
504    /// sends the whole run on to its next request. Replaying only part of it would make every later
505    /// turn's prefix diverge from that, throwing away the cache hit the replay is there to buy.
506    #[tokio::test]
507    async fn a_run_of_reasoning_from_one_response_is_replayed_whole() {
508        insert_data!(:tx);
509
510        assert_eq!(
511            replayed_shape(tx.as_mut(), |id| vec![
512                chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
513                chatbot_reasoning_message(id, "rs_2", "resp_1", Some("payload")),
514                chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
515                chatbot_tool_output_message(id, "call_1", ToolKind::Function, "resp_1"),
516                chatbot_tool_call_message(id, "call_2", ToolKind::Function, "resp_1"),
517                chatbot_tool_output_message(id, "call_2", ToolKind::Function, "resp_1"),
518            ])
519            .await,
520            vec![
521                "reasoning:rs_1",
522                "reasoning:rs_2",
523                "call:call_1",
524                "output:call_1",
525                "call:call_2",
526                "output:call_2",
527            ],
528        );
529    }
530
531    /// Mistral is not served through Azure OpenAI, so a prompt cache key means nothing to it. The
532    /// guard is one match arm, and losing it sends the parameter to an API that never asked for it.
533    #[test]
534    fn a_model_that_is_not_azure_openai_gets_no_prompt_cache_key() {
535        let conversation_id = Uuid::new_v4();
536
537        assert_eq!(
538            conversation_prompt_cache_key(&ModelType::Mistral, conversation_id),
539            None
540        );
541        assert!(
542            conversation_prompt_cache_key(&ModelType::GPTHardThinking, conversation_id).is_some()
543        );
544    }
545}