Skip to main content

headless_lms_chatbot/
llm_utils.rs

1use secrecy::{ExposeSecret, SecretString};
2
3use crate::{
4    azure_chatbot::azure::protocol::{
5        InputItem, LLMRequest, LLMRequestParams, LLMRequestResponseFormatParam, MistralParams,
6        NonThinkingParams, OutputItem, Reasoning, ReasoningContext, ReasoningOutput,
7        RequestTextOptions, Response as AzureResponse, ResponseError, ResponseReasoning,
8        SummaryType, ThinkingParams, Usage,
9    },
10    azure_chatbot::azure::tools::AZURE_AI_SEARCH_TOOL_NAME,
11    chatbot_error::ChatbotResult,
12    chatbot_tools::tool_is_answered_by_client,
13    prelude::*,
14};
15use core::default::Default;
16use headless_lms_base::config::{
17    ApplicationConfiguration, AzureChatbotConfiguration, AzureConfiguration,
18    AzureSearchConfiguration,
19};
20use headless_lms_models::{
21    chatbot_configurations::{ChatbotConfiguration, ReasoningEffortLevel},
22    chatbot_configurations_models::ModelType,
23    chatbot_conversation_message_messages::{ChatbotConversationMessageMessage, MessageRole},
24    chatbot_conversation_message_reasoning::ChatbotConversationMessageReasoning,
25    chatbot_conversation_message_tool_calls::{ChatbotConversationMessageToolCall, ToolKind},
26    chatbot_conversation_message_tool_outputs::ChatbotConversationMessageToolOutput,
27    chatbot_conversation_messages::{ChatbotConversationMessage, Message},
28};
29use headless_lms_utils::json_schema_types::{Schema, string_array_property};
30use indexmap::IndexMap;
31use reqwest::Response;
32use reqwest::header::HeaderMap;
33use serde::{Deserialize, Serialize};
34use tracing::{debug, error, instrument, trace, warn};
35
36/// The Azure section of the application configuration, or an error naming what's missing.
37pub fn azure_configuration(
38    app_config: &ApplicationConfiguration,
39) -> ChatbotResult<&AzureConfiguration> {
40    app_config.azure_configuration.as_ref().ok_or_else(|| {
41        chatbot_err!(
42            AzureRequestBuildError,
43            "Azure configuration is missing from the application configuration"
44        )
45    })
46}
47
48/// The Azure AI Search section of the application configuration, or an error naming what's missing.
49pub fn azure_search_configuration(
50    app_config: &ApplicationConfiguration,
51) -> ChatbotResult<&AzureSearchConfiguration> {
52    azure_configuration(app_config)?
53        .search_config
54        .as_ref()
55        .ok_or_else(|| {
56            chatbot_err!(
57                AzureRequestBuildError,
58                "Search configuration is missing from the Azure configuration"
59            )
60        })
61}
62
63/// The Azure chatbot (Foundry) section of the application configuration, or an error naming
64/// what's missing.
65pub fn azure_chatbot_configuration(
66    app_config: &ApplicationConfiguration,
67) -> ChatbotResult<&AzureChatbotConfiguration> {
68    azure_configuration(app_config)?
69        .chatbot_config
70        .as_ref()
71        .ok_or_else(|| {
72            chatbot_err!(
73                AzureRequestBuildError,
74                "Chatbot configuration is missing from the Azure configuration"
75            )
76        })
77}
78
79/// Common message structure used for LLM API requests
80#[derive(Serialize, Deserialize, Debug, Clone)]
81pub struct APIOutputMessage {
82    #[serde(flatten)]
83    pub message_type: OutputItem,
84}
85
86/// Common message structure used for LLM API requests
87#[derive(Serialize, Deserialize, Debug, Clone)]
88pub struct APIInputMessage {
89    #[serde(flatten)]
90    pub message_type: InputItem,
91}
92
93/// The single summary text that [`ChatbotConversationMessageReasoning::summary`] can hold, or
94/// `None` for an item with no summary. Azure streams a summary in parts; a row keeps one.
95fn summary_text(parts: &[ReasoningOutput]) -> Option<String> {
96    if parts.is_empty() {
97        return None;
98    }
99    Some(
100        parts
101            .iter()
102            .map(|part| part.text.as_str())
103            .collect::<Vec<_>>()
104            .join(" "),
105    )
106}
107
108/// A stored summary text as the summary parts that go back to Azure.
109fn stored_summary(text: Option<String>) -> Vec<ReasoningOutput> {
110    text.into_iter()
111        .map(|text| ReasoningOutput {
112            output_type: "summary_text".to_string(),
113            text,
114        })
115        .collect()
116}
117
118/// Summary parts as the database round trip spells them.
119///
120/// A reasoning item goes back to Azure twice, once from memory during the turn that produced it
121/// and again from storage on every later turn, and Azure caches on an exact prefix. Both spellings
122/// have to agree or the second one misses the cache for everything from that item onwards, so the
123/// round trip is spelled here rather than at each site that performs one of its halves.
124fn summary_as_stored(parts: &[ReasoningOutput]) -> Vec<ReasoningOutput> {
125    stored_summary(summary_text(parts))
126}
127
128impl From<APIOutputMessage> for APIInputMessage {
129    fn from(message: APIOutputMessage) -> Self {
130        match message.message_type {
131            // Flattened to text: a stored message can only come back as text, and the parts Azure
132            // streams lose their `type` on the way in, which the API rejects on the way back.
133            OutputItem::Message { role, content, .. } => APIInputMessage {
134                message_type: InputItem::Message {
135                    role,
136                    content: MessageContent::Text(content.get_content_text()),
137                },
138            },
139            OutputItem::FunctionCall {
140                call_id,
141                tool_name,
142                arguments,
143                ..
144            } => APIInputMessage {
145                message_type: InputItem::FunctionCall {
146                    call_id,
147                    tool_name,
148                    arguments,
149                },
150            },
151            OutputItem::FunctionCallOutput {
152                call_id, output, ..
153            } => APIInputMessage {
154                message_type: InputItem::FunctionCallOutput { call_id, output },
155            },
156            OutputItem::AzureAiSearchCall {
157                call_id, arguments, ..
158            } => APIInputMessage {
159                message_type: InputItem::FunctionCall {
160                    call_id,
161                    tool_name: AZURE_AI_SEARCH_TOOL_NAME.to_string(),
162                    arguments,
163                },
164            },
165            OutputItem::AzureAiSearchCallOutput {
166                call_id, output, ..
167            } => APIInputMessage {
168                message_type: InputItem::FunctionCallOutput { call_id, output },
169            },
170            OutputItem::Reasoning {
171                id,
172                summary,
173                encrypted_content,
174                ..
175            } => APIInputMessage {
176                message_type: InputItem::Reasoning {
177                    id,
178                    summary: summary_as_stored(&summary),
179                    encrypted_content,
180                },
181            },
182        }
183    }
184}
185
186impl TryFrom<ChatbotConversationMessage> for APIInputMessage {
187    type Error = ChatbotError;
188
189    fn try_from(message: ChatbotConversationMessage) -> Result<Self, Self::Error> {
190        let res = match message.message {
191            Message::Text(text_message) => match text_message.message_role {
192                MessageRole::User | MessageRole::Assistant | MessageRole::Developer => {
193                    APIInputMessage {
194                        message_type: InputItem::Message {
195                            role: text_message.message_role,
196                            content: MessageContent::Text(text_message.text),
197                        },
198                    }
199                }
200                MessageRole::System => {
201                    return Err(chatbot_err!(
202                        InvalidMessageShape,
203                        "A 'role: system' type text-variant ChatbotConversationMessage shouldn't be saved into the database."
204                    ));
205                }
206            },
207            Message::ToolCall(tool_call) => APIInputMessage {
208                message_type: InputItem::FunctionCall {
209                    arguments: tool_call.arguments_json(),
210                    call_id: tool_call.tool_call_id,
211                    tool_name: if tool_call.tool_kind.is_provider_tool() {
212                        AZURE_AI_SEARCH_TOOL_NAME.to_string()
213                    } else {
214                        tool_call.tool_name
215                    },
216                },
217            },
218            Message::ToolOutput(tool_output) => APIInputMessage {
219                message_type: InputItem::FunctionCallOutput {
220                    call_id: tool_output.tool_call_id,
221                    output: tool_output.output,
222                },
223            },
224            Message::Reasoning(ChatbotConversationMessageReasoning {
225                reasoning_id,
226                summary,
227                encrypted_content,
228                ..
229            }) => APIInputMessage {
230                message_type: InputItem::Reasoning {
231                    id: reasoning_id,
232                    summary: stored_summary(summary),
233                    encrypted_content,
234                },
235            },
236        };
237        Result::Ok(res)
238    }
239}
240
241#[derive(Serialize, Deserialize, Debug, Clone)]
242#[serde(untagged)]
243pub enum MessageContent {
244    Text(String),
245    OutputText(Vec<MessageContentItem>),
246    Refusal(Vec<RefusalContentItem>),
247}
248
249#[derive(Serialize, Deserialize, Debug, Clone)]
250pub struct MessageContentItem {
251    pub text: String,
252}
253
254#[derive(Serialize, Deserialize, Debug, Clone)]
255pub struct RefusalContentItem {
256    pub refusal: String,
257}
258
259impl MessageContent {
260    pub fn get_content_text(self) -> String {
261        match self {
262            MessageContent::Text(msg_text) => msg_text,
263            MessageContent::OutputText(output) => output
264                .iter()
265                .map(|x| x.text.to_owned())
266                .collect::<Vec<String>>()
267                .join(""),
268            MessageContent::Refusal(refusal) => refusal
269                .iter()
270                .map(|x| x.refusal.to_owned())
271                .collect::<Vec<String>>()
272                .join(""),
273        }
274    }
275}
276
277impl APIOutputMessage {
278    /// Create a ChatbotConversationMessage from an APIMessage to save it into the DB.
279    /// Notice that the insert operation ignores some of the fields, like timestamps.
280    /// `to_chatbot_conversation_message` doesn't set the correct order_number field
281    /// value.
282    pub fn to_chatbot_conversation_message(
283        &self,
284        conversation_id: Uuid,
285    ) -> ChatbotResult<ChatbotConversationMessage> {
286        let res = match self.message_type.clone() {
287            OutputItem::Message {
288                role,
289                content,
290                response_id,
291                ..
292            } => {
293                let text = content.get_content_text();
294                let used_tokens = estimate_tokens(&text);
295
296                ChatbotConversationMessage {
297                    conversation_id,
298                    message: Message::Text(ChatbotConversationMessageMessage {
299                        text,
300                        message_role: role,
301                        message_is_complete: true,
302                        used_tokens,
303                        response_id: if role == MessageRole::User {
304                            None
305                        } else {
306                            Some(response_id)
307                        },
308                        ..Default::default()
309                    }),
310                    ..Default::default()
311                }
312            }
313            OutputItem::FunctionCall {
314                call_id,
315                tool_name,
316                arguments,
317                response_id,
318            } => {
319                // The only place a call's kind is decided, so the stored row and the engine agree
320                // on which calls suspend the turn.
321                let tool_kind = if tool_is_answered_by_client(&tool_name) {
322                    ToolKind::ClientTool
323                } else {
324                    ToolKind::Function
325                };
326                ChatbotConversationMessage {
327                    conversation_id,
328                    message: Message::ToolCall(ChatbotConversationMessageToolCall::new(
329                        call_id,
330                        tool_name,
331                        arguments,
332                        tool_kind,
333                        response_id,
334                    )),
335                    ..Default::default()
336                }
337            }
338            OutputItem::FunctionCallOutput {
339                call_id,
340                output,
341                response_id,
342            } => ChatbotConversationMessage {
343                conversation_id,
344                message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
345                    output,
346                    tool_call_id: call_id,
347                    tool_kind: ToolKind::Function,
348                    response_id,
349                    ..Default::default()
350                }),
351                ..Default::default()
352            },
353            OutputItem::AzureAiSearchCall {
354                call_id,
355                arguments,
356                response_id,
357            } => ChatbotConversationMessage {
358                conversation_id,
359                message: Message::ToolCall(ChatbotConversationMessageToolCall::new(
360                    call_id,
361                    AZURE_AI_SEARCH_TOOL_NAME.to_string(),
362                    arguments,
363                    ToolKind::AzureAiSearch,
364                    response_id,
365                )),
366                ..Default::default()
367            },
368            OutputItem::AzureAiSearchCallOutput {
369                call_id,
370                output,
371                response_id,
372            } => ChatbotConversationMessage {
373                conversation_id,
374                message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
375                    tool_call_id: call_id,
376                    tool_kind: ToolKind::AzureAiSearch,
377                    output,
378                    response_id,
379                    ..Default::default()
380                }),
381                ..Default::default()
382            },
383            OutputItem::Reasoning {
384                summary,
385                response_id,
386                id,
387                encrypted_content,
388            } => ChatbotConversationMessage {
389                conversation_id,
390                message: Message::Reasoning(ChatbotConversationMessageReasoning {
391                    summary: summary_text(&summary),
392                    response_id,
393                    reasoning_id: id,
394                    encrypted_content,
395                    ..Default::default()
396                }),
397                ..Default::default()
398            },
399        };
400        Result::Ok(res)
401    }
402}
403
404impl TryFrom<ChatbotConversationMessage> for APIOutputMessage {
405    type Error = ChatbotError;
406
407    fn try_from(message: ChatbotConversationMessage) -> ChatbotResult<Self> {
408        let res = match message.message {
409            Message::Text(text_message) => match text_message.message_role {
410                MessageRole::User | MessageRole::Assistant | MessageRole::Developer => {
411                    APIOutputMessage {
412                        message_type: OutputItem::Message {
413                            role: text_message.message_role,
414                            content: MessageContent::Text(text_message.text),
415                            response_id: if text_message.message_role == MessageRole::User {
416                                "".to_string()
417                            } else {
418                                text_message.response_id.ok_or(chatbot_err!(
419                                    Other,
420                                    "Can't convert ChatbotConversationMessage into APIOutputMessage: only a role='user' message may lack a response_id"
421                                ))?
422                            },
423                        },
424                    }
425                }
426                MessageRole::System => {
427                    return Err(chatbot_err!(
428                        InvalidMessageShape,
429                        "A 'role: system' type text-variant ChatbotConversationMessage shouldn't be saved into the database."
430                    ));
431                }
432            },
433            Message::ToolCall(tool_call) => {
434                let arguments = tool_call.arguments_json();
435                if tool_call.tool_kind.is_provider_tool() {
436                    APIOutputMessage {
437                        message_type: OutputItem::AzureAiSearchCall {
438                            call_id: tool_call.tool_call_id,
439                            arguments,
440                            response_id: tool_call.response_id,
441                        },
442                    }
443                } else {
444                    APIOutputMessage {
445                        message_type: OutputItem::FunctionCall {
446                            call_id: tool_call.tool_call_id,
447                            tool_name: tool_call.tool_name,
448                            arguments,
449                            response_id: tool_call.response_id,
450                        },
451                    }
452                }
453            }
454            Message::ToolOutput(tool_output) => APIOutputMessage::from(tool_output),
455            Message::Reasoning(reasoning) => APIOutputMessage {
456                message_type: OutputItem::Reasoning {
457                    summary: stored_summary(reasoning.summary),
458                    response_id: reasoning.response_id,
459                    id: reasoning.reasoning_id,
460                    encrypted_content: reasoning.encrypted_content,
461                },
462            },
463        };
464        Result::Ok(res)
465    }
466}
467
468impl From<ChatbotConversationMessageToolOutput> for APIOutputMessage {
469    fn from(value: ChatbotConversationMessageToolOutput) -> Self {
470        if value.tool_kind.is_provider_tool() {
471            APIOutputMessage {
472                message_type: OutputItem::AzureAiSearchCallOutput {
473                    response_id: value.response_id,
474                    call_id: value.tool_call_id,
475                    output: value.output,
476                },
477            }
478        } else {
479            APIOutputMessage {
480                message_type: OutputItem::FunctionCallOutput {
481                    call_id: value.tool_call_id,
482                    output: value.output,
483                    response_id: value.response_id,
484                },
485            }
486        }
487    }
488}
489
490impl TryFrom<APIOutputMessage> for ChatbotConversationMessageToolOutput {
491    type Error = ChatbotError;
492    fn try_from(value: APIOutputMessage) -> ChatbotResult<Self> {
493        match value.message_type {
494            OutputItem::FunctionCallOutput {
495                call_id,
496                output,
497                response_id,
498            } => Ok(ChatbotConversationMessageToolOutput {
499                output,
500                tool_call_id: call_id,
501                response_id,
502                ..Default::default()
503            }),
504            OutputItem::AzureAiSearchCallOutput {
505                response_id,
506                call_id,
507                output,
508            } => Ok(ChatbotConversationMessageToolOutput {
509                output,
510                tool_call_id: call_id,
511                response_id,
512                ..Default::default()
513            }),
514            _ => Err(chatbot_err!(
515                Other,
516                "Can't convert APIMessage to ChatbotConversationMessageToolOutput: APIMessage type is not OutputItem::FunctionCallOutput"
517            )),
518        }
519    }
520}
521
522#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
523pub struct APITool {
524    pub arguments: String,
525    pub name: String,
526}
527
528/// The `store: false` every request sends, as a type rather than a `bool` so that no construction
529/// site can send the other value.
530///
531/// Azure hands back the `encrypted_content` that makes a reasoning item replayable only when it is
532/// not keeping the response itself, and a kept response is retained for 30 days for nothing:
533/// nothing here ever reads one back by id.
534#[derive(Debug, Clone, Copy, PartialEq)]
535pub struct StoreDisabled;
536
537impl Serialize for StoreDisabled {
538    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
539        serializer.serialize_bool(false)
540    }
541}
542
543impl<'de> Deserialize<'de> for StoreDisabled {
544    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
545        if bool::deserialize(deserializer)? {
546            return Err(serde::de::Error::custom(
547                "a request that asks Azure to store the response is not one this service sends",
548            ));
549        }
550        Ok(StoreDisabled)
551    }
552}
553
554/// The `include` a reasoning request asks for, or `None` for a model that produces no reasoning.
555///
556/// Redundant on current deployments, where [`StoreDisabled`] alone is enough to get
557/// `encrypted_content` back. It goes out anyway because Azure's general Responses documentation
558/// still describes the field as required while its reasoning-model guide says it is not, and a
559/// deployment following the older contract would return reasoning items carrying no payload — which
560/// replays as nothing and fails no test.
561fn reasoning_include(params: &LLMRequestParams) -> Option<Vec<String>> {
562    match params {
563        LLMRequestParams::GPTThinking(_) => Some(vec!["reasoning.encrypted_content".to_string()]),
564        LLMRequestParams::GPTNonThinking(_) | LLMRequestParams::Mistral(_) => None,
565    }
566}
567
568/// Simple completion-focused LLM request for Azure OpenAI
569/// Note: In Azure OpenAI, the model is specified in the URL, not in the request body
570#[derive(Serialize, Deserialize, Debug)]
571pub struct AzureCompletionRequest {
572    #[serde(flatten)]
573    pub base: LLMRequest,
574    pub stream: bool,
575    pub store: StoreDisabled,
576    /// See [`reasoning_include`].
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub include: Option<Vec<String>>,
579}
580
581/// The wire shape of [`AzureCompletionRequest`], borrowing its conversation instead of owning it.
582///
583/// A turn's request grows every round, so building the outbound body from an owned
584/// `AzureCompletionRequest` would deep-copy the whole accumulated conversation per round just to
585/// serialize it. Serialize-only: nothing needs to read one of these back, so unlike its owned
586/// sibling it derives no `Deserialize`.
587#[derive(Serialize)]
588struct AzureCompletionRequestRef<'a> {
589    #[serde(flatten)]
590    base: &'a LLMRequest,
591    stream: bool,
592    store: StoreDisabled,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    include: Option<Vec<String>>,
595}
596
597/// Response from LLM for simple completions
598#[derive(Deserialize, Debug)]
599pub struct LLMResponse {
600    pub id: String,
601    pub output: Vec<APIOutputMessage>,
602    pub usage: Option<Usage>,
603    pub reasoning: Option<ResponseReasoning>,
604}
605
606/// The structured output format for a feature whose whole answer is a list of strings.
607///
608/// `name` is what Azure, and the test-mode mock Azure API, identify the format by, so it has to
609/// name the feature rather than the shape. `property` is the single key the list arrives under.
610pub fn string_list_response_format(name: &str, property: &str) -> LLMRequestResponseFormatParam {
611    LLMRequestResponseFormatParam::JsonSchema {
612        name: name.to_string(),
613        schema: Schema::strict_object(
614            IndexMap::from([(property.to_string(), string_array_property(None))]),
615            None,
616        ),
617        strict: true,
618    }
619}
620
621/// Builds common headers for LLM requests
622#[instrument(skip(api_key), fields(api_key_length = api_key.expose_secret().len()))]
623pub fn build_llm_headers(api_key: &SecretString) -> ChatbotResult<HeaderMap> {
624    trace!("Building LLM request headers");
625    let mut headers = HeaderMap::new();
626    headers.insert(
627        "api-key",
628        // Exposed only here, at the point the header value is constructed.
629        api_key.expose_secret().parse().map_err(|_e| {
630            error!("Failed to parse API key");
631            chatbot_err!(AzureRequestBuildError, "Invalid API key")
632        })?,
633    );
634    headers.insert(
635        "content-type",
636        "application/json".parse().map_err(|_e| {
637            error!("Failed to parse content-type header");
638            chatbot_err!(AzureRequestBuildError, "Internal error")
639        })?,
640    );
641    trace!("Successfully built headers");
642    Ok(headers)
643}
644
645/// A request to the Azure AI Search management API, carrying the headers every one of its
646/// endpoints wants. The search API key is exposed only here.
647pub fn azure_search_request(
648    method: reqwest::Method,
649    url: url::Url,
650    search_config: &AzureSearchConfiguration,
651) -> reqwest::RequestBuilder {
652    REQWEST_CLIENT
653        .request(method, url)
654        .header("Content-Type", "application/json")
655        .header("api-key", search_config.search_api_key.expose_secret())
656}
657
658/// Logs the shape and order of a request's `input` items when Azure rejects it.
659///
660/// Azure's item-shape errors (e.g. an out-of-place reasoning item whose `encrypted_content` "could
661/// not be verified") name an item by id but not by position, so diagnosing one from the error alone
662/// means guessing at what the request actually looked like. This spells out every item's type, id,
663/// and `response_id` in order, which is what identifies a reasoning item sitting next to the wrong
664/// call.
665pub(crate) fn summarize_input_for_log(input: &[APIInputMessage]) -> String {
666    input
667        .iter()
668        .map(|message| match &message.message_type {
669            InputItem::Message { role, .. } => format!("Message({role:?})"),
670            InputItem::FunctionCall {
671                call_id, tool_name, ..
672            } => format!("FunctionCall({tool_name}, {call_id})"),
673            InputItem::FunctionCallOutput { call_id, .. } => {
674                format!("FunctionCallOutput({call_id})")
675            }
676            InputItem::Reasoning {
677                id,
678                encrypted_content,
679                ..
680            } => format!(
681                "Reasoning({id}, encrypted_content={})",
682                if encrypted_content.is_some() {
683                    "present"
684                } else {
685                    "absent"
686                }
687            ),
688        })
689        .collect::<Vec<_>>()
690        .join(" -> ")
691}
692
693/// Estimate the number of tokens in a given text.
694#[instrument(skip(text), fields(text_length = text.len()))]
695pub fn estimate_tokens(text: &str) -> i32 {
696    trace!("Estimating tokens for text");
697    let text_length = text.chars().fold(0, |acc, c| {
698        let mut len = c.len_utf8() as i32;
699        if len > 1 {
700            // The longer the character is, the more likely the text around is taking up more tokens
701            len *= 2;
702        }
703        if c.is_ascii_punctuation() {
704            // Punctuation is less common and is thus less likely to be part of a token
705            len *= 2;
706        }
707        acc + len
708    });
709    // A token is roughly 4 characters
710    let estimated_tokens = text_length / 4;
711    trace!("Estimated {} tokens for text", estimated_tokens);
712    estimated_tokens
713}
714
715/// Makes a non-streaming request to an LLM
716#[instrument(skip(chat_request, endpoint, api_key), fields(
717    num_messages = chat_request.input.len(),
718    temperature,
719    max_tokens,
720    endpoint = %endpoint
721))]
722async fn make_llm_request(
723    chat_request: LLMRequest,
724    endpoint: &url::Url,
725    api_key: &SecretString,
726) -> ChatbotResult<LLMResponse> {
727    debug!(
728        "Preparing LLM request with {} messages",
729        chat_request.input.len()
730    );
731
732    trace!("Base request: {:?}", chat_request);
733
734    let request = AzureCompletionRequest {
735        include: reasoning_include(&chat_request.params),
736        base: chat_request,
737        stream: false,
738        store: StoreDisabled,
739    };
740
741    let headers = build_llm_headers(api_key)?;
742    debug!("Sending request to LLM endpoint: {}", endpoint);
743
744    let response = REQWEST_CLIENT
745        .post(endpoint.clone())
746        .headers(headers)
747        .json(&request)
748        .send()
749        .await?;
750
751    trace!("Received response from LLM");
752    process_llm_response(response, &request.base.input).await
753}
754
755/// Builds the error for a failed LLM HTTP response, parsing `error_text` as an Azure error body
756/// when possible and attaching it as the error's Azure source.
757fn llm_http_error(status: reqwest::StatusCode, error_text: String) -> ChatbotError {
758    let azure_response = serde_json::from_str::<AzureResponse>(&error_text);
759    match azure_response {
760        Ok(response) => {
761            let azure_error: Option<ResponseError> = response.error;
762            // Format the error message to be minimal and add the Azure source.
763            let mut error = chatbot_err!(
764                FailedAzureResponse,
765                format!(
766                    "Error calling LLM API: Status: {}. Error: {}",
767                    status,
768                    &azure_error
769                        .as_ref()
770                        .and_then(|e| e.code.to_owned())
771                        .or_else(|| azure_error.as_ref().and_then(|e| e.error_type.to_owned()))
772                        .unwrap_or(error_text)
773                )
774            );
775            if let Some(e) = azure_error {
776                error.add_azure_source(e);
777            };
778            error
779        }
780        // If Azure returned data in some other shape, just show the unparsed text.
781        Err(_) => chatbot_err!(
782            FailedAzureResponse,
783            format!(
784                "Error calling LLM API: Status: {}. Error: {}",
785                status, &error_text
786            )
787        ),
788    }
789}
790
791/// Process a non-streaming LLM response
792#[instrument(skip(response), fields(status = %response.status()))]
793async fn process_llm_response(
794    response: Response,
795    input: &[APIInputMessage],
796) -> ChatbotResult<LLMResponse> {
797    if !response.status().is_success() {
798        let status = response.status();
799        let error_text = response.text().await?;
800        error!(
801            status = %status,
802            error = %error_text,
803            input = %summarize_input_for_log(input),
804            "Error calling LLM API"
805        );
806        return Err(llm_http_error(status, error_text));
807    }
808
809    trace!("Processing successful LLM response");
810    // Parse the response
811    let completion: LLMResponse = response.json().await?;
812    debug!(
813        "Successfully processed LLM response with {} choices",
814        completion.output.len()
815    );
816    if let Some(usage) = &completion.usage {
817        usage.log("non_streaming", completion.reasoning.as_ref());
818    }
819    Ok(completion)
820}
821
822/// Makes a streaming request to an LLM
823#[instrument(skip(chat_request, app_config), fields(
824    num_messages = chat_request.input.len(),
825    temperature,
826    max_tokens
827))]
828pub async fn make_streaming_llm_request(
829    chat_request: &LLMRequest,
830    app_config: &ApplicationConfiguration,
831) -> ChatbotResult<Response> {
832    debug!(
833        "Preparing streaming LLM request with {} messages",
834        chat_request.input.len()
835    );
836    let chatbot_config = azure_chatbot_configuration(app_config)
837        .inspect_err(|_| error!("Azure chatbot configuration missing"))?;
838
839    let request = AzureCompletionRequestRef {
840        include: reasoning_include(&chat_request.params),
841        base: chat_request,
842        stream: true,
843        store: StoreDisabled,
844    };
845
846    let headers = build_llm_headers(&chatbot_config.api_key)?;
847    let api_endpoint = chatbot_config.responses_endpoint()?;
848    debug!(
849        "Sending streaming request to LLM endpoint: {}",
850        api_endpoint
851    );
852
853    let send = REQWEST_STREAMING_CLIENT
854        .post(api_endpoint)
855        .headers(headers)
856        .json(&request)
857        .send();
858    let response = tokio::time::timeout(STREAM_RESPONSE_HEADERS_TIMEOUT, send)
859        .await
860        .map_err(|_| {
861            chatbot_err!(
862                StreamEndedEarly,
863                format!(
864                    "The LLM did not send response headers within {} seconds",
865                    STREAM_RESPONSE_HEADERS_TIMEOUT.as_secs()
866                )
867            )
868        })??;
869
870    if !response.status().is_success() {
871        let status = response.status();
872        let error_text = response.text().await?;
873        error!(
874            status = %status,
875            error = %error_text,
876            input = %summarize_input_for_log(&request.base.input),
877            "Error calling streaming LLM API"
878        );
879        return Err(llm_http_error(status, error_text));
880    }
881
882    debug!("Successfully initiated streaming response");
883    Ok(response)
884}
885
886/// Makes a non-streaming request to an LLM using application configuration
887#[instrument(skip(chat_request, app_config), fields(
888    num_messages = chat_request.input.len(),
889    temperature,
890    max_tokens
891))]
892pub async fn make_blocking_llm_request(
893    chat_request: LLMRequest,
894    app_config: &ApplicationConfiguration,
895) -> ChatbotResult<LLMResponse> {
896    debug!(
897        "Preparing blocking LLM request with {} messages",
898        chat_request.input.len()
899    );
900    let chatbot_config = azure_chatbot_configuration(app_config)
901        .inspect_err(|_| error!("Azure chatbot configuration missing"))?;
902
903    let api_endpoint = chatbot_config.responses_endpoint()?;
904
905    trace!("Making LLM request to endpoint: {}", api_endpoint);
906    make_llm_request(chat_request, &api_endpoint, &chatbot_config.api_key).await
907}
908
909/// Collects all the completion choices to a string. Assumes the completion has only
910/// text message content, no tool calls or tool output.
911pub fn parse_text_completion(completion: LLMResponse) -> ChatbotResult<String> {
912    let res =
913    completion
914        .output
915        .into_iter()
916        .map(|x| match x.message_type {
917            OutputItem::Message {  content , ..} => Ok(content.get_content_text()),
918            OutputItem::Reasoning { .. } => Ok("".to_string()),
919            _ =>  Err(chatbot_err!( InvalidMessageShape, "It was assumed this LLM response contains only text, but a tool call or tool response was detected.")),
920        })
921        .collect::<ChatbotResult<Vec<String>>>()?
922        .join("");
923    if res.is_empty() {
924        return Err(chatbot_err!(
925            InvalidMessageShape,
926            "No content returned from LLM"
927        ));
928    };
929    Ok(res)
930}
931
932/// Sends `input` as a one-shot structured-JSON request and deserializes the reply as `T`.
933///
934/// `on_invalid_response` builds the error raised when the reply doesn't parse as `T`, so each
935/// caller can raise its own [`ChatbotErrorType`] and wording for that failure.
936pub async fn request_structured_json<T: serde::de::DeserializeOwned>(
937    input: Vec<APIInputMessage>,
938    model: String,
939    params: LLMRequestParams,
940    max_output_tokens: Option<i32>,
941    format: LLMRequestResponseFormatParam,
942    app_config: &ApplicationConfiguration,
943    on_invalid_response: impl FnOnce() -> ChatbotError,
944) -> ChatbotResult<T> {
945    let chat_request = LLMRequest {
946        max_output_tokens,
947        text: Some(RequestTextOptions {
948            verbosity: None,
949            format: Some(format),
950        }),
951        ..LLMRequest::new(model, input, params)
952    };
953    let completion = make_blocking_llm_request(chat_request, app_config).await?;
954    let content = parse_text_completion(completion)?;
955    serde_json::from_str(&content).map_err(|_| on_invalid_response())
956}
957
958pub fn get_params_for_model(
959    model_name: &str,
960    model_type: &ModelType,
961    configuration: Option<&ChatbotConfiguration>,
962) -> LLMRequestParams {
963    if model_name == "gpt-5.2-chat" {
964        return LLMRequestParams::GPTThinking(ThinkingParams {
965            reasoning: Some(Reasoning {
966                effort: ReasoningEffortLevel::Medium,
967                summary: Some(SummaryType::Detailed),
968                context: None,
969            }),
970        });
971    }
972    match model_type {
973        ModelType::GPTNonThinking => {
974            if let Some(conf) = configuration {
975                LLMRequestParams::GPTNonThinking(NonThinkingParams {
976                    temperature: Some(conf.temperature),
977                    top_p: Some(conf.top_p),
978                    frequency_penalty: Some(conf.frequency_penalty),
979                    presence_penalty: Some(conf.presence_penalty),
980                })
981            } else {
982                LLMRequestParams::GPTNonThinking(NonThinkingParams {
983                    temperature: None,
984                    top_p: None,
985                    frequency_penalty: None,
986                    presence_penalty: None,
987                })
988            }
989        }
990        ModelType::GPTHardThinking => {
991            // make sure the effort value is valid for the model type
992            let effort = if let Some(conf) = configuration {
993                if conf.reasoning_effort == ReasoningEffortLevel::Minimal {
994                    ReasoningEffortLevel::Low
995                } else {
996                    conf.reasoning_effort
997                }
998            } else {
999                ReasoningEffortLevel::None
1000            };
1001            LLMRequestParams::GPTThinking(ThinkingParams {
1002                reasoning: Some(Reasoning {
1003                    effort,
1004                    summary: Some(SummaryType::Detailed),
1005                    // Only this arm is guaranteed to be GPT-5.6, the one version that accepts the
1006                    // parameter, and it renders less context than 5.6's `all_turns` default.
1007                    context: Some(ReasoningContext::CurrentTurn),
1008                }),
1009            })
1010        }
1011        ModelType::GPTThinking => {
1012            // make sure the effort value is valid for the model type
1013            let effort = if let Some(conf) = configuration {
1014                if conf.reasoning_effort == ReasoningEffortLevel::None {
1015                    ReasoningEffortLevel::Minimal
1016                } else if conf.reasoning_effort == ReasoningEffortLevel::Xhigh {
1017                    ReasoningEffortLevel::High
1018                } else {
1019                    conf.reasoning_effort
1020                }
1021            } else {
1022                ReasoningEffortLevel::Minimal
1023            };
1024            LLMRequestParams::GPTThinking(ThinkingParams {
1025                reasoning: Some(Reasoning {
1026                    effort,
1027                    summary: Some(SummaryType::Detailed),
1028                    // Assigned by model name, so it also covers deployments older than GPT-5.6,
1029                    // which reject a reasoning context outright.
1030                    context: None,
1031                }),
1032            })
1033        }
1034        ModelType::Mistral => LLMRequestParams::Mistral(MistralParams { placeholder: true }),
1035    }
1036}
1037
1038/// Checks if the model_type is a thinking model type. This function defines
1039/// which model types are thinking (reasoning)
1040pub fn model_is_thinking(model_type: ModelType) -> bool {
1041    matches!(
1042        model_type,
1043        ModelType::GPTHardThinking | ModelType::GPTThinking
1044    )
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use crate::chatbot_tools::{
1050        ChatbotToolDeclaration,
1051        client_tools::ask_multiple_choice_question::AskMultipleChoiceQuestionTool,
1052    };
1053
1054    use super::*;
1055
1056    fn thinking_request() -> LLMRequest {
1057        LLMRequest {
1058            input: vec![],
1059            model: "gpt-5.6".to_string(),
1060            tools: vec![],
1061            tool_choice: None,
1062            parallel_tool_calls: None,
1063            max_output_tokens: None,
1064            text: None,
1065            prompt_cache_key: Some("a-key".to_string()),
1066            params: LLMRequestParams::GPTThinking(ThinkingParams {
1067                reasoning: Some(Reasoning {
1068                    effort: ReasoningEffortLevel::Medium,
1069                    summary: Some(SummaryType::Detailed),
1070                    context: None,
1071                }),
1072            }),
1073        }
1074    }
1075
1076    /// Asking for the payload explicitly is what keeps a deployment that follows Azure's older
1077    /// contract from returning reasoning items that replay as nothing. It is asked for only where a
1078    /// reasoning item can appear: Mistral is not an Azure OpenAI model at all, and a non-reasoning
1079    /// deployment has no reason to be offered a reasoning-only include value.
1080    #[test]
1081    fn only_a_reasoning_request_asks_for_the_encrypted_payload() {
1082        let thinking = reasoning_include(&thinking_request().params);
1083        assert_eq!(
1084            thinking.as_deref(),
1085            Some(["reasoning.encrypted_content".to_string()].as_slice())
1086        );
1087
1088        let non_thinking = LLMRequestParams::GPTNonThinking(NonThinkingParams {
1089            temperature: None,
1090            top_p: None,
1091            frequency_penalty: None,
1092            presence_penalty: None,
1093        });
1094        assert_eq!(reasoning_include(&non_thinking), None);
1095        assert_eq!(
1096            reasoning_include(&LLMRequestParams::Mistral(MistralParams {
1097                placeholder: true
1098            })),
1099            None
1100        );
1101
1102        let body = serde_json::to_value(AzureCompletionRequest {
1103            include: reasoning_include(&thinking_request().params),
1104            base: thinking_request(),
1105            stream: true,
1106            store: StoreDisabled,
1107        })
1108        .expect("the request serializes");
1109        assert_eq!(
1110            body["include"],
1111            serde_json::json!(["reasoning.encrypted_content"])
1112        );
1113    }
1114
1115    /// Storing the response is what makes Azure withhold the `encrypted_content` a later turn
1116    /// replays, so `store` has one right value on every request. A body that says otherwise, or
1117    /// says nothing, has to fail rather than be read as having declined.
1118    #[test]
1119    fn a_request_body_can_only_decline_azure_side_storage() {
1120        let body = serde_json::to_value(AzureCompletionRequest {
1121            include: None,
1122            base: thinking_request(),
1123            stream: true,
1124            store: StoreDisabled,
1125        })
1126        .expect("the request serializes");
1127
1128        assert_eq!(body["store"], serde_json::json!(false));
1129        assert!(
1130            serde_json::from_value::<AzureCompletionRequest>(body.clone()).is_ok(),
1131            "the body a request actually sends round-trips"
1132        );
1133
1134        let mut asks_for_storage = body.clone();
1135        asks_for_storage["store"] = serde_json::json!(true);
1136        assert!(
1137            serde_json::from_value::<AzureCompletionRequest>(asks_for_storage).is_err(),
1138            "a body that asks Azure to store the response is not representable"
1139        );
1140
1141        let mut silent = body;
1142        silent
1143            .as_object_mut()
1144            .expect("a request is a JSON object")
1145            .remove("store");
1146        assert!(serde_json::from_value::<AzureCompletionRequest>(silent).is_err());
1147    }
1148
1149    /// Only GPT-5.6 tolerates being told which reasoning to render: an older reasoning deployment
1150    /// rejects the parameter outright. [`ModelType::GPTHardThinking`] is always GPT-5.6 here, while
1151    /// `GPTThinking` is assigned by model name and so still covers older ones, and the
1152    /// `gpt-5.2-chat` override lands on that same variant from a name that is certainly not 5.6.
1153    #[test]
1154    fn only_the_model_type_that_is_certainly_gpt_5_6_asks_for_the_current_turn_context() {
1155        let context_of = |model_name: &str, model_type| {
1156            let params = get_params_for_model(model_name, &model_type, None);
1157            serde_json::to_value(params).expect("the params serialize")["reasoning"]["context"]
1158                .clone()
1159        };
1160
1161        assert_eq!(
1162            context_of("gpt-5.6", ModelType::GPTHardThinking),
1163            serde_json::json!("current_turn")
1164        );
1165        assert_eq!(
1166            context_of("gpt-5.4", ModelType::GPTThinking),
1167            serde_json::Value::Null
1168        );
1169        assert_eq!(
1170            context_of("gpt-5.2-chat", ModelType::GPTHardThinking),
1171            serde_json::Value::Null
1172        );
1173        assert_eq!(
1174            context_of("gpt-4.1", ModelType::GPTNonThinking),
1175            serde_json::Value::Null
1176        );
1177        assert_eq!(
1178            context_of("mistral", ModelType::Mistral),
1179            serde_json::Value::Null
1180        );
1181    }
1182
1183    /// The `type` tag of every [`OutputItem`] variant, which is what
1184    /// [`every_output_item_serializes_the_same_from_memory_as_from_storage`] measures its cases
1185    /// against. Kept honest by [`output_item_tag`].
1186    const EVERY_OUTPUT_ITEM_TAG: &[&str] = &[
1187        "azure_ai_search_call",
1188        "azure_ai_search_call_output",
1189        "function_call",
1190        "function_call_output",
1191        "message",
1192        "reasoning",
1193    ];
1194
1195    /// The tag one item serializes as. Matched exhaustively, so an added [`OutputItem`] variant
1196    /// fails to compile here instead of quietly going missing from [`EVERY_OUTPUT_ITEM_TAG`].
1197    fn output_item_tag(item: &OutputItem) -> &'static str {
1198        match item {
1199            OutputItem::Message { .. } => "message",
1200            OutputItem::Reasoning { .. } => "reasoning",
1201            OutputItem::AzureAiSearchCall { .. } => "azure_ai_search_call",
1202            OutputItem::AzureAiSearchCallOutput { .. } => "azure_ai_search_call_output",
1203            OutputItem::FunctionCall { .. } => "function_call",
1204            OutputItem::FunctionCallOutput { .. } => "function_call_output",
1205        }
1206    }
1207
1208    fn output_text(text: &str) -> MessageContentItem {
1209        MessageContentItem {
1210            text: text.to_string(),
1211        }
1212    }
1213
1214    fn summary_part(text: &str) -> ReasoningOutput {
1215        ReasoningOutput {
1216            output_type: "summary_text".to_string(),
1217            text: text.to_string(),
1218        }
1219    }
1220
1221    /// One case per shape a round can hand on, `OutputItem` variant by variant. The shapes that
1222    /// break the invariant below most easily are in here on purpose: a multi-part reasoning summary,
1223    /// a refusal that arrives as a content part, and arguments a row keeps as a JSON string.
1224    fn round_trip_cases() -> Vec<APIOutputMessage> {
1225        [
1226            OutputItem::Message {
1227                response_id: "resp_1".to_string(),
1228                role: MessageRole::Assistant,
1229                content: MessageContent::Text("Here you go.".to_string()),
1230            },
1231            OutputItem::Message {
1232                response_id: "resp_1".to_string(),
1233                role: MessageRole::Assistant,
1234                content: MessageContent::OutputText(vec![
1235                    output_text("First part. "),
1236                    output_text("Second part."),
1237                ]),
1238            },
1239            OutputItem::Message {
1240                response_id: "resp_1".to_string(),
1241                role: MessageRole::Assistant,
1242                content: MessageContent::Refusal(vec![RefusalContentItem {
1243                    refusal: "I cannot help with that.".to_string(),
1244                }]),
1245            },
1246            OutputItem::Reasoning {
1247                response_id: "resp_1".to_string(),
1248                id: "rs_1".to_string(),
1249                summary: vec![summary_part("First part."), summary_part("Second part.")],
1250                encrypted_content: Some("payload".to_string()),
1251            },
1252            OutputItem::Reasoning {
1253                response_id: "resp_1".to_string(),
1254                id: "rs_2".to_string(),
1255                summary: vec![],
1256                encrypted_content: None,
1257            },
1258            OutputItem::FunctionCall {
1259                response_id: "resp_1".to_string(),
1260                call_id: "call_1".to_string(),
1261                tool_name: "course_progress".to_string(),
1262                arguments: r#"{"query":"loops"}"#.to_string(),
1263            },
1264            OutputItem::FunctionCallOutput {
1265                response_id: "resp_1".to_string(),
1266                call_id: "call_1".to_string(),
1267                output: r#"{"completed":3}"#.to_string(),
1268            },
1269            OutputItem::AzureAiSearchCall {
1270                response_id: "resp_1".to_string(),
1271                call_id: "call_2".to_string(),
1272                arguments: r#"{"query":"loops"}"#.to_string(),
1273            },
1274            OutputItem::AzureAiSearchCallOutput {
1275                response_id: "resp_1".to_string(),
1276                call_id: "call_2".to_string(),
1277                output: r#"{"documents":[]}"#.to_string(),
1278            },
1279        ]
1280        .into_iter()
1281        .map(|message_type| APIOutputMessage { message_type })
1282        .collect()
1283    }
1284
1285    /// An item goes back to Azure twice: from memory during the round that produced it, and from
1286    /// storage on every turn after. Azure's prompt cache matches an exact prefix, so the two
1287    /// spellings have to be byte-identical or the conversation misses the cache from that item
1288    /// onwards and the model is shown something other than what it wrote.
1289    #[test]
1290    fn every_output_item_serializes_the_same_from_memory_as_from_storage() {
1291        let cases = round_trip_cases();
1292
1293        let mut covered: Vec<&str> = cases
1294            .iter()
1295            .map(|case| output_item_tag(&case.message_type))
1296            .collect();
1297        covered.sort_unstable();
1298        covered.dedup();
1299        assert_eq!(
1300            covered, EVERY_OUTPUT_ITEM_TAG,
1301            "every OutputItem variant needs a case"
1302        );
1303
1304        for from_azure in cases {
1305            assert_eq!(
1306                serde_json::to_value(&from_azure.message_type).expect("the item serializes")["type"],
1307                serde_json::json!(output_item_tag(&from_azure.message_type)),
1308            );
1309
1310            let during_the_turn = APIInputMessage::from(from_azure.clone());
1311            let stored = from_azure
1312                .to_chatbot_conversation_message(Uuid::new_v4())
1313                .expect("the item is storable");
1314            let on_a_later_turn = APIInputMessage::try_from(stored).expect("the row converts back");
1315
1316            assert_eq!(
1317                serde_json::to_string(&during_the_turn).expect("the in-memory item serializes"),
1318                serde_json::to_string(&on_a_later_turn).expect("the stored item serializes"),
1319                "{:?}",
1320                from_azure.message_type,
1321            );
1322        }
1323    }
1324
1325    /// Pins the JSON sent to Azure, not the Rust value, so that adding fields to the shared schema
1326    /// types cannot change what the features built on this ask the LLM for.
1327    #[test]
1328    fn a_string_list_response_format_is_the_schema_azure_expects() {
1329        let serialized = serde_json::to_value(string_list_response_format(
1330            "AFeatureResponse",
1331            "suggestions",
1332        ))
1333        .expect("the response format serializes");
1334        assert_eq!(
1335            serialized,
1336            serde_json::json!({
1337                "type": "json_schema",
1338                "name": "AFeatureResponse",
1339                "schema": {
1340                    "type": "object",
1341                    "properties": {
1342                        "suggestions": {
1343                            "type": "array",
1344                            "items": { "type": "string" }
1345                        }
1346                    },
1347                    "required": ["suggestions"],
1348                    "additionalProperties": false
1349                },
1350                "strict": true
1351            })
1352        );
1353    }
1354
1355    /// Untagged unit variants serialize as `null`, which Azure reads as a request for no summary,
1356    /// so this enum must not be untagged.
1357    #[test]
1358    fn the_reasoning_summary_type_serializes_as_the_name_azure_expects() {
1359        assert_eq!(
1360            serde_json::to_string(&SummaryType::Detailed).expect("the summary type serializes"),
1361            r#""detailed""#
1362        );
1363    }
1364
1365    const CLIENT_TOOL_NAME: &str = <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME;
1366
1367    /// Which calls suspend a turn is decided from the tool name, so a stored call cannot end up
1368    /// with a kind the engine disagrees with.
1369    #[test]
1370    fn a_stored_tool_call_gets_its_kind_from_the_tool_name() {
1371        for (tool_name, expected) in [
1372            (CLIENT_TOOL_NAME, ToolKind::ClientTool),
1373            ("course_structure", ToolKind::Function),
1374        ] {
1375            let message = APIOutputMessage {
1376                message_type: OutputItem::FunctionCall {
1377                    response_id: "resp_1".to_string(),
1378                    call_id: "call_1".to_string(),
1379                    tool_name: tool_name.to_string(),
1380                    arguments: "{}".to_string(),
1381                },
1382            }
1383            .to_chatbot_conversation_message(Uuid::new_v4())
1384            .expect("the call converts to a conversation message");
1385
1386            let Message::ToolCall(call) = message.message else {
1387                panic!("expected a tool call message");
1388            };
1389            assert_eq!(call.tool_kind, expected, "{tool_name}");
1390        }
1391    }
1392
1393    /// A client tool call is an ordinary function call to Azure: which of the two ends answers it
1394    /// is our business, not the provider's, so a resumed turn replays the proven shape.
1395    #[test]
1396    fn a_client_tool_call_and_its_answer_go_back_as_function_items() {
1397        let call = ChatbotConversationMessage {
1398            message: Message::ToolCall(ChatbotConversationMessageToolCall {
1399                tool_name: CLIENT_TOOL_NAME.to_string(),
1400                tool_call_id: "call_1".to_string(),
1401                tool_kind: ToolKind::ClientTool,
1402                response_id: "resp_1".to_string(),
1403                ..Default::default()
1404            }),
1405            ..Default::default()
1406        };
1407        match APIInputMessage::try_from(call)
1408            .expect("the call converts")
1409            .message_type
1410        {
1411            InputItem::FunctionCall {
1412                call_id, tool_name, ..
1413            } => {
1414                assert_eq!(call_id, "call_1");
1415                assert_eq!(tool_name, CLIENT_TOOL_NAME);
1416            }
1417            other => panic!("expected a function call, got {other:?}"),
1418        }
1419
1420        let answer = ChatbotConversationMessage {
1421            message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
1422                output: "the client answered".to_string(),
1423                tool_call_id: "call_1".to_string(),
1424                tool_kind: ToolKind::ClientTool,
1425                response_id: "resp_1".to_string(),
1426                ..Default::default()
1427            }),
1428            ..Default::default()
1429        };
1430        match APIInputMessage::try_from(answer)
1431            .expect("the answer converts")
1432            .message_type
1433        {
1434            InputItem::FunctionCallOutput { call_id, output } => {
1435                assert_eq!(call_id, "call_1");
1436                assert_eq!(output, "the client answered");
1437            }
1438            other => panic!("expected a function call output, got {other:?}"),
1439        }
1440    }
1441
1442    #[test]
1443    fn test_estimate_tokens() {
1444        // The real number is 4
1445        assert_eq!(estimate_tokens("Hello, world!"), 3);
1446        assert_eq!(estimate_tokens(""), 0);
1447        // The real number is 9
1448        assert_eq!(
1449            estimate_tokens("This is a longer sentence with several words."),
1450            11
1451        );
1452        // The real number is 7
1453        assert_eq!(estimate_tokens("Hyvää päivää!"), 7);
1454        // The real number is 9
1455        assert_eq!(estimate_tokens("トークンは楽しい"), 12);
1456        // The real number is 52
1457        assert_eq!(
1458            estimate_tokens("🙂🙃😀😃😄😁😆😅😂🤣😊😇🙂🙃😀😃😄😁😆😅😂🤣😊😇"),
1459            48
1460        );
1461        // The real number is 18
1462        assert_eq!(estimate_tokens("ฉันใช้โทเค็นทุกวัน"), 27);
1463        // The real number is 17
1464        assert_eq!(estimate_tokens("Жетони роблять мене щасливим"), 25);
1465    }
1466}