Skip to main content

headless_lms_chatbot/
llm_utils.rs

1use secrecy::{ExposeSecret, SecretString};
2
3use crate::{
4    azure_chatbot::{
5        InputItem, LLMRequest, LLMRequestParams, MistralParams, NonThinkingParams, OutputItem,
6        Reasoning, ReasoningOutput, Response as AzureResponse, ResponseError, SummaryType,
7        ThinkingParams,
8    },
9    chatbot_error::ChatbotResult,
10    prelude::*,
11};
12use core::default::Default;
13use headless_lms_base::config::ApplicationConfiguration;
14use headless_lms_models::{
15    chatbot_configurations::{ChatbotConfiguration, ReasoningEffortLevel},
16    chatbot_configurations_models::ModelType,
17    chatbot_conversation_message_messages::{ChatbotConversationMessageMessage, MessageRole},
18    chatbot_conversation_message_reasoning::ChatbotConversationMessageReasoning,
19    chatbot_conversation_message_tool_calls::{ChatbotConversationMessageToolCall, ToolKind},
20    chatbot_conversation_message_tool_outputs::ChatbotConversationMessageToolOutput,
21    chatbot_conversation_messages::{ChatbotConversationMessage, Message},
22};
23use reqwest::Response;
24use reqwest::header::HeaderMap;
25use serde::{Deserialize, Serialize};
26use tracing::{debug, error, instrument, trace, warn};
27
28/// Common message structure used for LLM API requests
29#[derive(Serialize, Deserialize, Debug, Clone)]
30pub struct APIOutputMessage {
31    #[serde(flatten)]
32    pub message_type: OutputItem,
33}
34
35/// Common message structure used for LLM API requests
36#[derive(Serialize, Deserialize, Debug, Clone)]
37pub struct APIInputMessage {
38    #[serde(flatten)]
39    pub message_type: InputItem,
40}
41
42impl From<APIOutputMessage> for APIInputMessage {
43    fn from(message: APIOutputMessage) -> Self {
44        match message.message_type {
45            OutputItem::Message { role, content, .. } => APIInputMessage {
46                message_type: InputItem::Message { role, content },
47            },
48            OutputItem::FunctionCall {
49                call_id,
50                tool_name,
51                arguments,
52                ..
53            } => APIInputMessage {
54                message_type: InputItem::FunctionCall {
55                    call_id,
56                    tool_name,
57                    arguments,
58                },
59            },
60            OutputItem::FunctionCallOutput {
61                call_id, output, ..
62            } => APIInputMessage {
63                message_type: InputItem::FunctionCallOutput { call_id, output },
64            },
65            OutputItem::AzureAiSearchCall {
66                call_id, arguments, ..
67            } => APIInputMessage {
68                message_type: InputItem::FunctionCall {
69                    call_id,
70                    tool_name: "azure_ai_search".to_string(),
71                    arguments,
72                },
73            },
74            OutputItem::AzureAiSearchCallOutput {
75                call_id, output, ..
76            } => APIInputMessage {
77                message_type: InputItem::FunctionCallOutput { call_id, output },
78            },
79            OutputItem::Reasoning { id, summary, .. } => APIInputMessage {
80                message_type: InputItem::Reasoning { id, summary },
81            },
82        }
83    }
84}
85
86impl TryFrom<ChatbotConversationMessage> for APIInputMessage {
87    type Error = ChatbotError;
88
89    fn try_from(message: ChatbotConversationMessage) -> Result<Self, Self::Error> {
90        let res = match message.message {
91            Message::Text(text_message) => match text_message.message_role {
92                MessageRole::User | MessageRole::Assistant => APIInputMessage {
93                    message_type: InputItem::Message {
94                        role: text_message.message_role,
95                        content: MessageContent::Text(text_message.text),
96                    },
97                },
98                _ => {
99                    return Err(chatbot_err!(
100                        InvalidMessageShape,
101                        "A 'role: system' or 'role: developer' type text-variant ChatbotConversationMessage shouldn't be saved into the database."
102                    ));
103                }
104            },
105            Message::ToolCall(tool_call) => match tool_call.tool_kind {
106                ToolKind::Function => APIInputMessage {
107                    message_type: InputItem::FunctionCall {
108                        call_id: tool_call.tool_call_id,
109                        tool_name: tool_call.tool_name,
110                        arguments: serde_json::to_string(&tool_call.tool_arguments)?,
111                    },
112                },
113                ToolKind::AzureAiSearch => APIInputMessage {
114                    message_type: InputItem::FunctionCall {
115                        call_id: tool_call.tool_call_id,
116                        tool_name: "azure_ai_search".to_string(),
117                        arguments: serde_json::to_string(&tool_call.tool_arguments)?,
118                    },
119                },
120            },
121            Message::ToolOutput(tool_output) => match tool_output.tool_kind {
122                ToolKind::Function => APIInputMessage {
123                    message_type: InputItem::FunctionCallOutput {
124                        call_id: tool_output.tool_call_id,
125                        output: tool_output.output,
126                    },
127                },
128                ToolKind::AzureAiSearch => APIInputMessage {
129                    message_type: InputItem::FunctionCallOutput {
130                        call_id: tool_output.tool_call_id,
131                        output: tool_output.output,
132                    },
133                },
134            },
135            Message::Reasoning(ChatbotConversationMessageReasoning {
136                reasoning_id,
137                summary,
138                ..
139            }) => {
140                let summ = if let Some(text) = summary {
141                    vec![ReasoningOutput {
142                        output_type: "summary_text".to_string(),
143                        text,
144                    }]
145                } else {
146                    vec![]
147                };
148                APIInputMessage {
149                    message_type: InputItem::Reasoning {
150                        id: reasoning_id,
151                        summary: summ,
152                    },
153                }
154            }
155        };
156        Result::Ok(res)
157    }
158}
159
160#[derive(Serialize, Deserialize, Debug, Clone)]
161#[serde(untagged)]
162pub enum MessageContent {
163    Text(String),
164    OutputText(Vec<MessageContentItem>),
165    Refusal(Vec<RefusalContentItem>),
166}
167
168#[derive(Serialize, Deserialize, Debug, Clone)]
169pub struct MessageContentItem {
170    pub text: String,
171}
172
173#[derive(Serialize, Deserialize, Debug, Clone)]
174pub struct RefusalContentItem {
175    pub refusal: String,
176}
177
178impl MessageContent {
179    pub fn get_content_text(self) -> String {
180        match self {
181            MessageContent::Text(msg_text) => msg_text,
182            MessageContent::OutputText(output) => output
183                .iter()
184                .map(|x| x.text.to_owned())
185                .collect::<Vec<String>>()
186                .join(""),
187            MessageContent::Refusal(refusal) => refusal
188                .iter()
189                .map(|x| x.refusal.to_owned())
190                .collect::<Vec<String>>()
191                .join(""),
192        }
193    }
194}
195
196impl APIOutputMessage {
197    /// Create a ChatbotConversationMessage from an APIMessage to save it into the DB.
198    /// Notice that the insert operation ignores some of the fields, like timestamps.
199    /// `to_chatbot_conversation_message` doesn't set the correct order_number field
200    /// value.
201    pub fn to_chatbot_conversation_message(
202        &self,
203        conversation_id: Uuid,
204    ) -> ChatbotResult<ChatbotConversationMessage> {
205        let res = match self.message_type.clone() {
206            OutputItem::Message {
207                role,
208                content,
209                response_id,
210                ..
211            } => {
212                let text = content.get_content_text();
213                let used_tokens = estimate_tokens(&text);
214
215                ChatbotConversationMessage {
216                    conversation_id,
217                    message: Message::Text(ChatbotConversationMessageMessage {
218                        text,
219                        message_role: role,
220                        message_is_complete: true,
221                        used_tokens,
222                        response_id: if role == MessageRole::User {
223                            None
224                        } else {
225                            Some(response_id)
226                        },
227                        ..Default::default()
228                    }),
229                    ..Default::default()
230                }
231            }
232            OutputItem::FunctionCall {
233                call_id,
234                tool_name,
235                arguments,
236                response_id,
237            } => ChatbotConversationMessage {
238                conversation_id,
239                message: Message::ToolCall(ChatbotConversationMessageToolCall {
240                    tool_name,
241                    tool_arguments: serde_json::to_value(arguments)?,
242                    tool_call_id: call_id,
243                    tool_kind: ToolKind::Function,
244                    response_id,
245                    ..Default::default()
246                }),
247                ..Default::default()
248            },
249            OutputItem::FunctionCallOutput {
250                call_id,
251                output,
252                response_id,
253            } => ChatbotConversationMessage {
254                conversation_id,
255                message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
256                    output,
257                    tool_call_id: call_id,
258                    tool_kind: ToolKind::Function,
259                    response_id,
260                    ..Default::default()
261                }),
262                ..Default::default()
263            },
264            OutputItem::AzureAiSearchCall {
265                call_id,
266                arguments,
267                response_id,
268            } => ChatbotConversationMessage {
269                conversation_id,
270                message: Message::ToolCall(ChatbotConversationMessageToolCall {
271                    tool_arguments: serde_json::to_value(arguments)?,
272                    tool_call_id: call_id,
273                    tool_kind: ToolKind::AzureAiSearch,
274                    tool_name: "azure_ai_search".to_string(),
275                    response_id,
276                    ..Default::default()
277                }),
278                ..Default::default()
279            },
280            OutputItem::AzureAiSearchCallOutput {
281                call_id,
282                output,
283                response_id,
284            } => ChatbotConversationMessage {
285                conversation_id,
286                message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
287                    tool_call_id: call_id,
288                    tool_kind: ToolKind::AzureAiSearch,
289                    output,
290                    response_id,
291                    ..Default::default()
292                }),
293                ..Default::default()
294            },
295            OutputItem::Reasoning {
296                summary,
297                response_id,
298                id,
299            } => {
300                let text = if !summary.is_empty() {
301                    Some(
302                        summary
303                            .iter()
304                            .map(|i| i.text.to_owned())
305                            .collect::<Vec<String>>()
306                            .join(" "),
307                    )
308                } else {
309                    None
310                };
311                ChatbotConversationMessage {
312                    conversation_id,
313                    message: Message::Reasoning(ChatbotConversationMessageReasoning {
314                        summary: text,
315                        response_id,
316                        reasoning_id: id,
317                        ..Default::default()
318                    }),
319                    ..Default::default()
320                }
321            }
322        };
323        Result::Ok(res)
324    }
325}
326
327impl TryFrom<ChatbotConversationMessage> for APIOutputMessage {
328    type Error = ChatbotError;
329
330    fn try_from(message: ChatbotConversationMessage) -> ChatbotResult<Self> {
331        let res = match message.message {
332            Message::Text(text_message) => match text_message.message_role {
333                MessageRole::User | MessageRole::Assistant => APIOutputMessage {
334                    message_type: OutputItem::Message {
335                        role: text_message.message_role,
336                        content: MessageContent::Text(text_message.text),
337                        response_id: if text_message.message_role == MessageRole::User {
338                            "".to_string()
339                        } else {
340                            text_message.response_id.ok_or(chatbot_err!(
341                                    Other,
342                                    "Can't convert ChatbotConversationMessage into APIOutputMessage: a role='assistant' message should have a response_id, but it's missing"
343                                ))?
344                        },
345                        phase: None,
346                    },
347                },
348                _ => {
349                    return Err(chatbot_err!(
350                        InvalidMessageShape,
351                        "A 'role: system' or 'role: developer' type text-variant ChatbotConversationMessage shouldn't be saved into the database."
352                    ));
353                }
354            },
355            Message::ToolCall(tool_call) => match tool_call.tool_kind {
356                ToolKind::Function => APIOutputMessage {
357                    message_type: OutputItem::FunctionCall {
358                        call_id: tool_call.tool_call_id,
359                        tool_name: tool_call.tool_name,
360                        arguments: serde_json::to_string(&tool_call.tool_arguments)?,
361                        response_id: tool_call.response_id,
362                    },
363                },
364                ToolKind::AzureAiSearch => APIOutputMessage {
365                    message_type: OutputItem::AzureAiSearchCall {
366                        call_id: tool_call.tool_call_id,
367                        arguments: serde_json::to_string(&tool_call.tool_arguments)?,
368                        response_id: tool_call.response_id,
369                    },
370                },
371            },
372            Message::ToolOutput(tool_output) => match tool_output.tool_kind {
373                ToolKind::Function => APIOutputMessage {
374                    message_type: OutputItem::FunctionCallOutput {
375                        call_id: tool_output.tool_call_id,
376                        output: tool_output.output,
377                        response_id: tool_output.response_id,
378                    },
379                },
380                ToolKind::AzureAiSearch => APIOutputMessage {
381                    message_type: OutputItem::AzureAiSearchCallOutput {
382                        call_id: tool_output.tool_call_id,
383                        output: tool_output.output,
384                        response_id: tool_output.response_id,
385                    },
386                },
387            },
388            Message::Reasoning(reasoning) => {
389                if let Some(text) = reasoning.summary {
390                    APIOutputMessage {
391                        message_type: OutputItem::Reasoning {
392                            summary: vec![ReasoningOutput {
393                                output_type: "summary_text".to_string(),
394                                text,
395                            }],
396                            response_id: reasoning.response_id,
397                            id: reasoning.reasoning_id,
398                        },
399                    }
400                } else {
401                    APIOutputMessage {
402                        message_type: OutputItem::Reasoning {
403                            summary: vec![],
404                            response_id: reasoning.response_id,
405                            id: reasoning.reasoning_id,
406                        },
407                    }
408                }
409            }
410        };
411        Result::Ok(res)
412    }
413}
414
415impl From<ChatbotConversationMessageToolOutput> for APIOutputMessage {
416    fn from(value: ChatbotConversationMessageToolOutput) -> Self {
417        match value.tool_kind {
418            ToolKind::Function => APIOutputMessage {
419                message_type: OutputItem::FunctionCallOutput {
420                    call_id: value.tool_call_id,
421                    output: value.output,
422                    response_id: value.response_id,
423                },
424            },
425            ToolKind::AzureAiSearch => APIOutputMessage {
426                message_type: OutputItem::AzureAiSearchCallOutput {
427                    response_id: value.response_id,
428                    call_id: value.tool_call_id,
429                    output: value.output,
430                },
431            },
432        }
433    }
434}
435
436impl TryFrom<APIOutputMessage> for ChatbotConversationMessageToolOutput {
437    type Error = ChatbotError;
438    fn try_from(value: APIOutputMessage) -> ChatbotResult<Self> {
439        match value.message_type {
440            OutputItem::FunctionCallOutput {
441                call_id,
442                output,
443                response_id,
444            } => Ok(ChatbotConversationMessageToolOutput {
445                output,
446                tool_call_id: call_id,
447                response_id,
448                ..Default::default()
449            }),
450            OutputItem::AzureAiSearchCallOutput {
451                response_id,
452                call_id,
453                output,
454            } => Ok(ChatbotConversationMessageToolOutput {
455                output,
456                tool_call_id: call_id,
457                response_id,
458                ..Default::default()
459            }),
460            _ => Err(chatbot_err!(
461                Other,
462                "Can't convert APIMessage to ChatbotConversationMessageToolOutput: APIMessage type is not OutputItem::FunctionCallOutput"
463            )),
464        }
465    }
466}
467
468/// An LLM tool call that is part of a request to Azure
469#[derive(Serialize, Deserialize, Debug, Clone)]
470pub struct APIToolCall {
471    pub function: APITool,
472    pub id: String,
473    #[serde(rename = "type")]
474    pub tool_type: ToolKind,
475}
476
477impl From<ChatbotConversationMessageToolCall> for APIToolCall {
478    fn from(value: ChatbotConversationMessageToolCall) -> Self {
479        APIToolCall {
480            function: APITool {
481                arguments: value.tool_arguments.to_string(),
482                name: value.tool_name,
483            },
484            id: value.tool_call_id,
485            tool_type: value.tool_kind,
486        }
487    }
488}
489
490impl TryFrom<APIToolCall> for ChatbotConversationMessageToolCall {
491    type Error = ChatbotError;
492    fn try_from(value: APIToolCall) -> ChatbotResult<Self> {
493        Ok(ChatbotConversationMessageToolCall {
494            tool_name: value.function.name,
495            tool_arguments: serde_json::from_str(&value.function.arguments)?,
496            tool_call_id: value.id,
497            tool_kind: value.tool_type,
498            ..Default::default()
499        })
500    }
501}
502
503#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
504pub struct APITool {
505    pub arguments: String,
506    pub name: String,
507}
508
509/// Simple completion-focused LLM request for Azure OpenAI
510/// Note: In Azure OpenAI, the model is specified in the URL, not in the request body
511#[derive(Serialize, Deserialize, Debug)]
512pub struct AzureCompletionRequest {
513    #[serde(flatten)]
514    pub base: LLMRequest,
515    pub stream: bool,
516}
517
518/// Response from LLM for simple completions
519#[derive(Deserialize, Debug)]
520pub struct LLMResponse {
521    pub id: String,
522    pub output: Vec<APIOutputMessage>,
523}
524
525/// Builds common headers for LLM requests
526#[instrument(skip(api_key), fields(api_key_length = api_key.expose_secret().len()))]
527pub fn build_llm_headers(api_key: &SecretString) -> ChatbotResult<HeaderMap> {
528    trace!("Building LLM request headers");
529    let mut headers = HeaderMap::new();
530    headers.insert(
531        "api-key",
532        // Exposed only here, at the point the header value is constructed.
533        api_key.expose_secret().parse().map_err(|_e| {
534            error!("Failed to parse API key");
535            chatbot_err!(AzureRequestBuildError, "Invalid API key")
536        })?,
537    );
538    headers.insert(
539        "content-type",
540        "application/json".parse().map_err(|_e| {
541            error!("Failed to parse content-type header");
542            chatbot_err!(AzureRequestBuildError, "Internal error")
543        })?,
544    );
545    trace!("Successfully built headers");
546    Ok(headers)
547}
548
549/// Estimate the number of tokens in a given text.
550#[instrument(skip(text), fields(text_length = text.len()))]
551pub fn estimate_tokens(text: &str) -> i32 {
552    trace!("Estimating tokens for text");
553    let text_length = text.chars().fold(0, |acc, c| {
554        let mut len = c.len_utf8() as i32;
555        if len > 1 {
556            // The longer the character is, the more likely the text around is taking up more tokens
557            len *= 2;
558        }
559        if c.is_ascii_punctuation() {
560            // Punctuation is less common and is thus less likely to be part of a token
561            len *= 2;
562        }
563        acc + len
564    });
565    // A token is roughly 4 characters
566    let estimated_tokens = text_length / 4;
567    trace!("Estimated {} tokens for text", estimated_tokens);
568    estimated_tokens
569}
570
571/// Makes a non-streaming request to an LLM
572#[instrument(skip(chat_request, endpoint, api_key), fields(
573    num_messages = chat_request.input.len(),
574    temperature,
575    max_tokens,
576    endpoint = %endpoint
577))]
578async fn make_llm_request(
579    chat_request: LLMRequest,
580    endpoint: &url::Url,
581    api_key: &SecretString,
582) -> ChatbotResult<LLMResponse> {
583    debug!(
584        "Preparing LLM request with {} messages",
585        chat_request.input.len()
586    );
587
588    trace!("Base request: {:?}", chat_request);
589
590    let request = AzureCompletionRequest {
591        base: chat_request,
592        stream: false,
593    };
594
595    let headers = build_llm_headers(api_key)?;
596    debug!("Sending request to LLM endpoint: {}", endpoint);
597
598    let response = REQWEST_CLIENT
599        .post(endpoint.clone())
600        .headers(headers)
601        .json(&request)
602        .send()
603        .await?;
604
605    trace!("Received response from LLM");
606    process_llm_response(response).await
607}
608
609/// Process a non-streaming LLM response
610#[instrument(skip(response), fields(status = %response.status()))]
611async fn process_llm_response(response: Response) -> ChatbotResult<LLMResponse> {
612    if !response.status().is_success() {
613        let status = response.status();
614        let error_text = response.text().await?;
615        error!(
616            status = %status,
617            error = %error_text,
618            "Error calling LLM API"
619        );
620        // Azure can return a JSON Response, so parse it and take the error from it.
621        let azure_response = serde_json::from_str::<AzureResponse>(&error_text);
622        let error = match azure_response {
623            Ok(response) => {
624                let azure_error: Option<ResponseError> = response.error;
625                // Format the error message to be minimal and add the Azure source.
626                let mut error = chatbot_err!(
627                    FailedAzureResponse,
628                    format!(
629                        "Error calling LLM API: Status: {}. Error: {}",
630                        status,
631                        &azure_error
632                            .as_ref()
633                            .and_then(|e| e.code.to_owned())
634                            .or_else(|| azure_error.as_ref().and_then(|e| e.error_type.to_owned()))
635                            .unwrap_or(error_text)
636                    )
637                );
638                if let Some(e) = azure_error {
639                    error.add_azure_source(e);
640                };
641                error
642            }
643            // If Azure returned data in some other shape, just show the unparsed text.
644            Err(_) => chatbot_err!(
645                FailedAzureResponse,
646                format!(
647                    "Error calling LLM API: Status: {}. Error: {}",
648                    status, &error_text
649                )
650            ),
651        };
652
653        return Err(error);
654    }
655
656    trace!("Processing successful LLM response");
657    // Parse the response
658    let completion: LLMResponse = response.json().await?;
659    debug!(
660        "Successfully processed LLM response with {} choices",
661        completion.output.len()
662    );
663    Ok(completion)
664}
665
666/// Makes a streaming request to an LLM
667#[instrument(skip(chat_request, app_config), fields(
668    num_messages = chat_request.input.len(),
669    temperature,
670    max_tokens
671))]
672pub async fn make_streaming_llm_request(
673    chat_request: LLMRequest,
674    app_config: &ApplicationConfiguration,
675) -> ChatbotResult<Response> {
676    debug!(
677        "Preparing streaming LLM request with {} messages",
678        chat_request.input.len()
679    );
680    let azure_config = app_config.azure_configuration.as_ref().ok_or_else(|| {
681        error!("Azure configuration missing");
682        chatbot_err!(
683            AzureRequestBuildError,
684            "Azure configuration is missing from the application configuration"
685        )
686    })?;
687
688    let chatbot_config = azure_config.chatbot_config.as_ref().ok_or_else(|| {
689        error!("Chatbot configuration missing");
690        chatbot_err!(
691            AzureRequestBuildError,
692            "Chatbot configuration is missing from the Azure configuration"
693        )
694    })?;
695
696    let request = AzureCompletionRequest {
697        base: chat_request,
698        stream: true,
699    };
700
701    let headers = build_llm_headers(&chatbot_config.api_key)?;
702    let api_endpoint = chatbot_config.api_endpoint.to_owned();
703    debug!(
704        "Sending streaming request to LLM endpoint: {}",
705        api_endpoint
706    );
707
708    let response = REQWEST_CLIENT
709        .post(api_endpoint)
710        .headers(headers)
711        .json(&request)
712        .send()
713        .await?;
714
715    if !response.status().is_success() {
716        let status = response.status();
717        let error_text = response.text().await?;
718        error!(
719            status = %status,
720            error = %error_text,
721            "Error calling streaming LLM API"
722        );
723        // Azure can return a JSON Response, so parse it and take the error from it.
724        let azure_response = serde_json::from_str::<AzureResponse>(&error_text);
725        let error = match azure_response {
726            Ok(response) => {
727                let azure_error: Option<ResponseError> = response.error;
728                // Format the error message to be minimal and add the Azure source.
729                let mut error = chatbot_err!(
730                    FailedAzureResponse,
731                    format!(
732                        "Error calling LLM API: Status: {}. Error: {}",
733                        status,
734                        &azure_error
735                            .as_ref()
736                            .and_then(|e| e.code.to_owned())
737                            .or_else(|| azure_error.as_ref().and_then(|e| e.error_type.to_owned()))
738                            .unwrap_or(error_text)
739                    )
740                );
741                if let Some(e) = azure_error {
742                    error.add_azure_source(e);
743                };
744                error
745            }
746            // If Azure returned data in some other shape, just show the unparsed text.
747            Err(_) => chatbot_err!(
748                FailedAzureResponse,
749                format!(
750                    "Error calling LLM API: Status: {}. Error: {}",
751                    status, &error_text
752                )
753            ),
754        };
755
756        return Err(error);
757    }
758
759    debug!("Successfully initiated streaming response");
760    Ok(response)
761}
762
763/// Makes a non-streaming request to an LLM using application configuration
764#[instrument(skip(chat_request, app_config), fields(
765    num_messages = chat_request.input.len(),
766    temperature,
767    max_tokens
768))]
769pub async fn make_blocking_llm_request(
770    chat_request: LLMRequest,
771    app_config: &ApplicationConfiguration,
772) -> ChatbotResult<LLMResponse> {
773    debug!(
774        "Preparing blocking LLM request with {} messages",
775        chat_request.input.len()
776    );
777    let azure_config = app_config.azure_configuration.as_ref().ok_or_else(|| {
778        error!("Azure configuration missing");
779        chatbot_err!(
780            AzureRequestBuildError,
781            "Azure configuration is missing from the application configuration"
782        )
783    })?;
784
785    let chatbot_config = azure_config.chatbot_config.as_ref().ok_or_else(|| {
786        error!("Chatbot configuration missing");
787        chatbot_err!(
788            AzureRequestBuildError,
789            "Chatbot configuration is missing from the Azure configuration"
790        )
791    })?;
792
793    let api_endpoint = chatbot_config.api_endpoint.to_owned();
794
795    trace!("Making LLM request to endpoint: {}", api_endpoint);
796    make_llm_request(chat_request, &api_endpoint, &chatbot_config.api_key).await
797}
798
799/// Collects all the completion choices to a string. Assumes the completion has only
800/// text message content, no tool calls or tool output.
801pub fn parse_text_completion(completion: LLMResponse) -> ChatbotResult<String> {
802    let res =
803    completion
804        .output
805        .into_iter()
806        .map(|x| match x.message_type {
807            OutputItem::Message {  content , ..} => Ok(content.get_content_text()),
808            OutputItem::Reasoning { .. } => Ok("".to_string()),
809            _ =>  Err(chatbot_err!( InvalidMessageShape, "It was assumed this LLM response contains only text, but a tool call or tool response was detected.")),
810        })
811        .collect::<ChatbotResult<Vec<String>>>()?
812        .join("");
813    if res.is_empty() {
814        return Err(chatbot_err!(
815            InvalidMessageShape,
816            "No content returned from LLM"
817        ));
818    };
819    Ok(res)
820}
821
822pub fn get_params_for_model(
823    model_name: &str,
824    model_type: &ModelType,
825    configuration: Option<&ChatbotConfiguration>,
826) -> LLMRequestParams {
827    if model_name == "gpt-5.2-chat" {
828        return LLMRequestParams::GPTThinking(ThinkingParams {
829            reasoning: Some(Reasoning {
830                effort: ReasoningEffortLevel::Medium,
831                summary: Some(SummaryType::Detailed),
832            }),
833        });
834    }
835    match model_type {
836        ModelType::GPTNonThinking => {
837            if let Some(conf) = configuration {
838                LLMRequestParams::GPTNonThinking(NonThinkingParams {
839                    temperature: Some(conf.temperature),
840                    top_p: Some(conf.top_p),
841                    frequency_penalty: Some(conf.frequency_penalty),
842                    presence_penalty: Some(conf.presence_penalty),
843                })
844            } else {
845                LLMRequestParams::GPTNonThinking(NonThinkingParams {
846                    temperature: None,
847                    top_p: None,
848                    frequency_penalty: None,
849                    presence_penalty: None,
850                })
851            }
852        }
853        ModelType::GPTHardThinking => {
854            // make sure the effort value is valid for the model type
855            let effort = if let Some(conf) = configuration {
856                if conf.reasoning_effort == ReasoningEffortLevel::Minimal {
857                    ReasoningEffortLevel::Low
858                } else {
859                    conf.reasoning_effort
860                }
861            } else {
862                ReasoningEffortLevel::None
863            };
864            LLMRequestParams::GPTThinking(ThinkingParams {
865                reasoning: Some(Reasoning {
866                    effort,
867                    summary: Some(SummaryType::Detailed),
868                }),
869            })
870        }
871        ModelType::GPTThinking => {
872            // make sure the effort value is valid for the model type
873            let effort = if let Some(conf) = configuration {
874                if conf.reasoning_effort == ReasoningEffortLevel::None {
875                    ReasoningEffortLevel::Minimal
876                } else if conf.reasoning_effort == ReasoningEffortLevel::Xhigh {
877                    ReasoningEffortLevel::High
878                } else {
879                    conf.reasoning_effort
880                }
881            } else {
882                ReasoningEffortLevel::Minimal
883            };
884            LLMRequestParams::GPTThinking(ThinkingParams {
885                reasoning: Some(Reasoning {
886                    effort,
887                    summary: Some(SummaryType::Detailed),
888                }),
889            })
890        }
891        ModelType::Mistral => LLMRequestParams::Mistral(MistralParams { test: true }),
892    }
893}
894
895/// Checks if the model_type is a thinking model type. This function defines
896/// which model types are thinking (reasoning)
897pub fn model_is_thinking(model_type: ModelType) -> bool {
898    matches!(
899        model_type,
900        ModelType::GPTHardThinking | ModelType::GPTThinking
901    )
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907
908    #[test]
909    fn test_estimate_tokens() {
910        // The real number is 4
911        assert_eq!(estimate_tokens("Hello, world!"), 3);
912        assert_eq!(estimate_tokens(""), 0);
913        // The real number is 9
914        assert_eq!(
915            estimate_tokens("This is a longer sentence with several words."),
916            11
917        );
918        // The real number is 7
919        assert_eq!(estimate_tokens("Hyvää päivää!"), 7);
920        // The real number is 9
921        assert_eq!(estimate_tokens("トークンは楽しい"), 12);
922        // The real number is 52
923        assert_eq!(
924            estimate_tokens("🙂🙃😀😃😄😁😆😅😂🤣😊😇🙂🙃😀😃😄😁😆😅😂🤣😊😇"),
925            48
926        );
927        // The real number is 18
928        assert_eq!(estimate_tokens("ฉันใช้โทเค็นทุกวัน"), 27);
929        // The real number is 17
930        assert_eq!(estimate_tokens("Жетони роблять мене щасливим"), 25);
931    }
932}