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::{JSONType, 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 {
612        format_type: JSONType::JsonSchema,
613        name: name.to_string(),
614        schema: Schema::strict_object(
615            IndexMap::from([(property.to_string(), string_array_property(None))]),
616            None,
617        ),
618        strict: true,
619    }
620}
621
622/// Builds common headers for LLM requests
623#[instrument(skip(api_key), fields(api_key_length = api_key.expose_secret().len()))]
624pub fn build_llm_headers(api_key: &SecretString) -> ChatbotResult<HeaderMap> {
625    trace!("Building LLM request headers");
626    let mut headers = HeaderMap::new();
627    headers.insert(
628        "api-key",
629        // Exposed only here, at the point the header value is constructed.
630        api_key.expose_secret().parse().map_err(|_e| {
631            error!("Failed to parse API key");
632            chatbot_err!(AzureRequestBuildError, "Invalid API key")
633        })?,
634    );
635    headers.insert(
636        "content-type",
637        "application/json".parse().map_err(|_e| {
638            error!("Failed to parse content-type header");
639            chatbot_err!(AzureRequestBuildError, "Internal error")
640        })?,
641    );
642    trace!("Successfully built headers");
643    Ok(headers)
644}
645
646/// A request to the Azure AI Search management API, carrying the headers every one of its
647/// endpoints wants. The search API key is exposed only here.
648pub fn azure_search_request(
649    method: reqwest::Method,
650    url: url::Url,
651    search_config: &AzureSearchConfiguration,
652) -> reqwest::RequestBuilder {
653    REQWEST_CLIENT
654        .request(method, url)
655        .header("Content-Type", "application/json")
656        .header("api-key", search_config.search_api_key.expose_secret())
657}
658
659/// Logs the shape and order of a request's `input` items when Azure rejects it.
660///
661/// Azure's item-shape errors (e.g. an out-of-place reasoning item whose `encrypted_content` "could
662/// not be verified") name an item by id but not by position, so diagnosing one from the error alone
663/// means guessing at what the request actually looked like. This spells out every item's type, id,
664/// and `response_id` in order, which is what identifies a reasoning item sitting next to the wrong
665/// call.
666pub(crate) fn summarize_input_for_log(input: &[APIInputMessage]) -> String {
667    input
668        .iter()
669        .map(|message| match &message.message_type {
670            InputItem::Message { role, .. } => format!("Message({role:?})"),
671            InputItem::FunctionCall {
672                call_id, tool_name, ..
673            } => format!("FunctionCall({tool_name}, {call_id})"),
674            InputItem::FunctionCallOutput { call_id, .. } => {
675                format!("FunctionCallOutput({call_id})")
676            }
677            InputItem::Reasoning {
678                id,
679                encrypted_content,
680                ..
681            } => format!(
682                "Reasoning({id}, encrypted_content={})",
683                if encrypted_content.is_some() {
684                    "present"
685                } else {
686                    "absent"
687                }
688            ),
689        })
690        .collect::<Vec<_>>()
691        .join(" -> ")
692}
693
694/// Estimate the number of tokens in a given text.
695#[instrument(skip(text), fields(text_length = text.len()))]
696pub fn estimate_tokens(text: &str) -> i32 {
697    trace!("Estimating tokens for text");
698    let text_length = text.chars().fold(0, |acc, c| {
699        let mut len = c.len_utf8() as i32;
700        if len > 1 {
701            // The longer the character is, the more likely the text around is taking up more tokens
702            len *= 2;
703        }
704        if c.is_ascii_punctuation() {
705            // Punctuation is less common and is thus less likely to be part of a token
706            len *= 2;
707        }
708        acc + len
709    });
710    // A token is roughly 4 characters
711    let estimated_tokens = text_length / 4;
712    trace!("Estimated {} tokens for text", estimated_tokens);
713    estimated_tokens
714}
715
716/// Makes a non-streaming request to an LLM
717#[instrument(skip(chat_request, endpoint, api_key), fields(
718    num_messages = chat_request.input.len(),
719    temperature,
720    max_tokens,
721    endpoint = %endpoint
722))]
723async fn make_llm_request(
724    chat_request: LLMRequest,
725    endpoint: &url::Url,
726    api_key: &SecretString,
727) -> ChatbotResult<LLMResponse> {
728    debug!(
729        "Preparing LLM request with {} messages",
730        chat_request.input.len()
731    );
732
733    trace!("Base request: {:?}", chat_request);
734
735    let request = AzureCompletionRequest {
736        include: reasoning_include(&chat_request.params),
737        base: chat_request,
738        stream: false,
739        store: StoreDisabled,
740    };
741
742    let headers = build_llm_headers(api_key)?;
743    debug!("Sending request to LLM endpoint: {}", endpoint);
744
745    let response = REQWEST_CLIENT
746        .post(endpoint.clone())
747        .headers(headers)
748        .json(&request)
749        .send()
750        .await?;
751
752    trace!("Received response from LLM");
753    process_llm_response(response, &request.base.input).await
754}
755
756/// Builds the error for a failed LLM HTTP response, parsing `error_text` as an Azure error body
757/// when possible and attaching it as the error's Azure source.
758fn llm_http_error(status: reqwest::StatusCode, error_text: String) -> ChatbotError {
759    let azure_response = serde_json::from_str::<AzureResponse>(&error_text);
760    match azure_response {
761        Ok(response) => {
762            let azure_error: Option<ResponseError> = response.error;
763            // Format the error message to be minimal and add the Azure source.
764            let mut error = chatbot_err!(
765                FailedAzureResponse,
766                format!(
767                    "Error calling LLM API: Status: {}. Error: {}",
768                    status,
769                    &azure_error
770                        .as_ref()
771                        .and_then(|e| e.code.to_owned())
772                        .or_else(|| azure_error.as_ref().and_then(|e| e.error_type.to_owned()))
773                        .unwrap_or(error_text)
774                )
775            );
776            if let Some(e) = azure_error {
777                error.add_azure_source(e);
778            };
779            error
780        }
781        // If Azure returned data in some other shape, just show the unparsed text.
782        Err(_) => chatbot_err!(
783            FailedAzureResponse,
784            format!(
785                "Error calling LLM API: Status: {}. Error: {}",
786                status, &error_text
787            )
788        ),
789    }
790}
791
792/// Process a non-streaming LLM response
793#[instrument(skip(response), fields(status = %response.status()))]
794async fn process_llm_response(
795    response: Response,
796    input: &[APIInputMessage],
797) -> ChatbotResult<LLMResponse> {
798    if !response.status().is_success() {
799        let status = response.status();
800        let error_text = response.text().await?;
801        error!(
802            status = %status,
803            error = %error_text,
804            input = %summarize_input_for_log(input),
805            "Error calling LLM API"
806        );
807        return Err(llm_http_error(status, error_text));
808    }
809
810    trace!("Processing successful LLM response");
811    // Parse the response
812    let completion: LLMResponse = response.json().await?;
813    debug!(
814        "Successfully processed LLM response with {} choices",
815        completion.output.len()
816    );
817    if let Some(usage) = &completion.usage {
818        usage.log("non_streaming", completion.reasoning.as_ref());
819    }
820    Ok(completion)
821}
822
823/// Makes a streaming request to an LLM
824#[instrument(skip(chat_request, app_config), fields(
825    num_messages = chat_request.input.len(),
826    temperature,
827    max_tokens
828))]
829pub async fn make_streaming_llm_request(
830    chat_request: &LLMRequest,
831    app_config: &ApplicationConfiguration,
832) -> ChatbotResult<Response> {
833    debug!(
834        "Preparing streaming LLM request with {} messages",
835        chat_request.input.len()
836    );
837    let chatbot_config = azure_chatbot_configuration(app_config)
838        .inspect_err(|_| error!("Azure chatbot configuration missing"))?;
839
840    let request = AzureCompletionRequestRef {
841        include: reasoning_include(&chat_request.params),
842        base: chat_request,
843        stream: true,
844        store: StoreDisabled,
845    };
846
847    let headers = build_llm_headers(&chatbot_config.api_key)?;
848    let api_endpoint = chatbot_config.responses_endpoint()?;
849    debug!(
850        "Sending streaming request to LLM endpoint: {}",
851        api_endpoint
852    );
853
854    let send = REQWEST_STREAMING_CLIENT
855        .post(api_endpoint)
856        .headers(headers)
857        .json(&request)
858        .send();
859    let response = tokio::time::timeout(STREAM_RESPONSE_HEADERS_TIMEOUT, send)
860        .await
861        .map_err(|_| {
862            chatbot_err!(
863                StreamEndedEarly,
864                format!(
865                    "The LLM did not send response headers within {} seconds",
866                    STREAM_RESPONSE_HEADERS_TIMEOUT.as_secs()
867                )
868            )
869        })??;
870
871    if !response.status().is_success() {
872        let status = response.status();
873        let error_text = response.text().await?;
874        error!(
875            status = %status,
876            error = %error_text,
877            input = %summarize_input_for_log(&request.base.input),
878            "Error calling streaming LLM API"
879        );
880        return Err(llm_http_error(status, error_text));
881    }
882
883    debug!("Successfully initiated streaming response");
884    Ok(response)
885}
886
887/// Makes a non-streaming request to an LLM using application configuration
888#[instrument(skip(chat_request, app_config), fields(
889    num_messages = chat_request.input.len(),
890    temperature,
891    max_tokens
892))]
893pub async fn make_blocking_llm_request(
894    chat_request: LLMRequest,
895    app_config: &ApplicationConfiguration,
896) -> ChatbotResult<LLMResponse> {
897    debug!(
898        "Preparing blocking LLM request with {} messages",
899        chat_request.input.len()
900    );
901    let chatbot_config = azure_chatbot_configuration(app_config)
902        .inspect_err(|_| error!("Azure chatbot configuration missing"))?;
903
904    let api_endpoint = chatbot_config.responses_endpoint()?;
905
906    trace!("Making LLM request to endpoint: {}", api_endpoint);
907    make_llm_request(chat_request, &api_endpoint, &chatbot_config.api_key).await
908}
909
910/// Collects all the completion choices to a string. Assumes the completion has only
911/// text message content, no tool calls or tool output.
912pub fn parse_text_completion(completion: LLMResponse) -> ChatbotResult<String> {
913    let res =
914    completion
915        .output
916        .into_iter()
917        .map(|x| match x.message_type {
918            OutputItem::Message {  content , ..} => Ok(content.get_content_text()),
919            OutputItem::Reasoning { .. } => Ok("".to_string()),
920            _ =>  Err(chatbot_err!( InvalidMessageShape, "It was assumed this LLM response contains only text, but a tool call or tool response was detected.")),
921        })
922        .collect::<ChatbotResult<Vec<String>>>()?
923        .join("");
924    if res.is_empty() {
925        return Err(chatbot_err!(
926            InvalidMessageShape,
927            "No content returned from LLM"
928        ));
929    };
930    Ok(res)
931}
932
933/// Sends `input` as a one-shot structured-JSON request and deserializes the reply as `T`.
934///
935/// `on_invalid_response` builds the error raised when the reply doesn't parse as `T`, so each
936/// caller can raise its own [`ChatbotErrorType`] and wording for that failure.
937pub async fn request_structured_json<T: serde::de::DeserializeOwned>(
938    input: Vec<APIInputMessage>,
939    model: String,
940    params: LLMRequestParams,
941    max_output_tokens: Option<i32>,
942    format: LLMRequestResponseFormatParam,
943    app_config: &ApplicationConfiguration,
944    on_invalid_response: impl FnOnce() -> ChatbotError,
945) -> ChatbotResult<T> {
946    let chat_request = LLMRequest {
947        max_output_tokens,
948        text: Some(RequestTextOptions {
949            verbosity: None,
950            format: Some(format),
951        }),
952        ..LLMRequest::new(model, input, params)
953    };
954    let completion = make_blocking_llm_request(chat_request, app_config).await?;
955    let content = parse_text_completion(completion)?;
956    serde_json::from_str(&content).map_err(|_| on_invalid_response())
957}
958
959pub fn get_params_for_model(
960    model_name: &str,
961    model_type: &ModelType,
962    configuration: Option<&ChatbotConfiguration>,
963) -> LLMRequestParams {
964    if model_name == "gpt-5.2-chat" {
965        return LLMRequestParams::GPTThinking(ThinkingParams {
966            reasoning: Some(Reasoning {
967                effort: ReasoningEffortLevel::Medium,
968                summary: Some(SummaryType::Detailed),
969                context: None,
970            }),
971        });
972    }
973    match model_type {
974        ModelType::GPTNonThinking => {
975            if let Some(conf) = configuration {
976                LLMRequestParams::GPTNonThinking(NonThinkingParams {
977                    temperature: Some(conf.temperature),
978                    top_p: Some(conf.top_p),
979                    frequency_penalty: Some(conf.frequency_penalty),
980                    presence_penalty: Some(conf.presence_penalty),
981                })
982            } else {
983                LLMRequestParams::GPTNonThinking(NonThinkingParams {
984                    temperature: None,
985                    top_p: None,
986                    frequency_penalty: None,
987                    presence_penalty: None,
988                })
989            }
990        }
991        ModelType::GPTHardThinking => {
992            // make sure the effort value is valid for the model type
993            let effort = if let Some(conf) = configuration {
994                if conf.reasoning_effort == ReasoningEffortLevel::Minimal {
995                    ReasoningEffortLevel::Low
996                } else {
997                    conf.reasoning_effort
998                }
999            } else {
1000                ReasoningEffortLevel::None
1001            };
1002            LLMRequestParams::GPTThinking(ThinkingParams {
1003                reasoning: Some(Reasoning {
1004                    effort,
1005                    summary: Some(SummaryType::Detailed),
1006                    // Only this arm is guaranteed to be GPT-5.6, the one version that accepts the
1007                    // parameter, and it renders less context than 5.6's `all_turns` default.
1008                    context: Some(ReasoningContext::CurrentTurn),
1009                }),
1010            })
1011        }
1012        ModelType::GPTThinking => {
1013            // make sure the effort value is valid for the model type
1014            let effort = if let Some(conf) = configuration {
1015                if conf.reasoning_effort == ReasoningEffortLevel::None {
1016                    ReasoningEffortLevel::Minimal
1017                } else if conf.reasoning_effort == ReasoningEffortLevel::Xhigh {
1018                    ReasoningEffortLevel::High
1019                } else {
1020                    conf.reasoning_effort
1021                }
1022            } else {
1023                ReasoningEffortLevel::Minimal
1024            };
1025            LLMRequestParams::GPTThinking(ThinkingParams {
1026                reasoning: Some(Reasoning {
1027                    effort,
1028                    summary: Some(SummaryType::Detailed),
1029                    // Assigned by model name, so it also covers deployments older than GPT-5.6,
1030                    // which reject a reasoning context outright.
1031                    context: None,
1032                }),
1033            })
1034        }
1035        ModelType::Mistral => LLMRequestParams::Mistral(MistralParams { placeholder: true }),
1036    }
1037}
1038
1039/// Checks if the model_type is a thinking model type. This function defines
1040/// which model types are thinking (reasoning)
1041pub fn model_is_thinking(model_type: ModelType) -> bool {
1042    matches!(
1043        model_type,
1044        ModelType::GPTHardThinking | ModelType::GPTThinking
1045    )
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use crate::chatbot_tools::{
1051        ChatbotToolDeclaration,
1052        client_tools::ask_multiple_choice_question::AskMultipleChoiceQuestionTool,
1053    };
1054
1055    use super::*;
1056
1057    fn thinking_request() -> LLMRequest {
1058        LLMRequest {
1059            input: vec![],
1060            model: "gpt-5.6".to_string(),
1061            tools: vec![],
1062            tool_choice: None,
1063            parallel_tool_calls: None,
1064            max_output_tokens: None,
1065            text: None,
1066            prompt_cache_key: Some("a-key".to_string()),
1067            params: LLMRequestParams::GPTThinking(ThinkingParams {
1068                reasoning: Some(Reasoning {
1069                    effort: ReasoningEffortLevel::Medium,
1070                    summary: Some(SummaryType::Detailed),
1071                    context: None,
1072                }),
1073            }),
1074        }
1075    }
1076
1077    /// Asking for the payload explicitly is what keeps a deployment that follows Azure's older
1078    /// contract from returning reasoning items that replay as nothing. It is asked for only where a
1079    /// reasoning item can appear: Mistral is not an Azure OpenAI model at all, and a non-reasoning
1080    /// deployment has no reason to be offered a reasoning-only include value.
1081    #[test]
1082    fn only_a_reasoning_request_asks_for_the_encrypted_payload() {
1083        let thinking = reasoning_include(&thinking_request().params);
1084        assert_eq!(
1085            thinking.as_deref(),
1086            Some(["reasoning.encrypted_content".to_string()].as_slice())
1087        );
1088
1089        let non_thinking = LLMRequestParams::GPTNonThinking(NonThinkingParams {
1090            temperature: None,
1091            top_p: None,
1092            frequency_penalty: None,
1093            presence_penalty: None,
1094        });
1095        assert_eq!(reasoning_include(&non_thinking), None);
1096        assert_eq!(
1097            reasoning_include(&LLMRequestParams::Mistral(MistralParams {
1098                placeholder: true
1099            })),
1100            None
1101        );
1102
1103        let body = serde_json::to_value(AzureCompletionRequest {
1104            include: reasoning_include(&thinking_request().params),
1105            base: thinking_request(),
1106            stream: true,
1107            store: StoreDisabled,
1108        })
1109        .expect("the request serializes");
1110        assert_eq!(
1111            body["include"],
1112            serde_json::json!(["reasoning.encrypted_content"])
1113        );
1114    }
1115
1116    /// Storing the response is what makes Azure withhold the `encrypted_content` a later turn
1117    /// replays, so `store` has one right value on every request. A body that says otherwise, or
1118    /// says nothing, has to fail rather than be read as having declined.
1119    #[test]
1120    fn a_request_body_can_only_decline_azure_side_storage() {
1121        let body = serde_json::to_value(AzureCompletionRequest {
1122            include: None,
1123            base: thinking_request(),
1124            stream: true,
1125            store: StoreDisabled,
1126        })
1127        .expect("the request serializes");
1128
1129        assert_eq!(body["store"], serde_json::json!(false));
1130        assert!(
1131            serde_json::from_value::<AzureCompletionRequest>(body.clone()).is_ok(),
1132            "the body a request actually sends round-trips"
1133        );
1134
1135        let mut asks_for_storage = body.clone();
1136        asks_for_storage["store"] = serde_json::json!(true);
1137        assert!(
1138            serde_json::from_value::<AzureCompletionRequest>(asks_for_storage).is_err(),
1139            "a body that asks Azure to store the response is not representable"
1140        );
1141
1142        let mut silent = body;
1143        silent
1144            .as_object_mut()
1145            .expect("a request is a JSON object")
1146            .remove("store");
1147        assert!(serde_json::from_value::<AzureCompletionRequest>(silent).is_err());
1148    }
1149
1150    /// Only GPT-5.6 tolerates being told which reasoning to render: an older reasoning deployment
1151    /// rejects the parameter outright. [`ModelType::GPTHardThinking`] is always GPT-5.6 here, while
1152    /// `GPTThinking` is assigned by model name and so still covers older ones, and the
1153    /// `gpt-5.2-chat` override lands on that same variant from a name that is certainly not 5.6.
1154    #[test]
1155    fn only_the_model_type_that_is_certainly_gpt_5_6_asks_for_the_current_turn_context() {
1156        let context_of = |model_name: &str, model_type| {
1157            let params = get_params_for_model(model_name, &model_type, None);
1158            serde_json::to_value(params).expect("the params serialize")["reasoning"]["context"]
1159                .clone()
1160        };
1161
1162        assert_eq!(
1163            context_of("gpt-5.6", ModelType::GPTHardThinking),
1164            serde_json::json!("current_turn")
1165        );
1166        assert_eq!(
1167            context_of("gpt-5.4", ModelType::GPTThinking),
1168            serde_json::Value::Null
1169        );
1170        assert_eq!(
1171            context_of("gpt-5.2-chat", ModelType::GPTHardThinking),
1172            serde_json::Value::Null
1173        );
1174        assert_eq!(
1175            context_of("gpt-4.1", ModelType::GPTNonThinking),
1176            serde_json::Value::Null
1177        );
1178        assert_eq!(
1179            context_of("mistral", ModelType::Mistral),
1180            serde_json::Value::Null
1181        );
1182    }
1183
1184    /// The `type` tag of every [`OutputItem`] variant, which is what
1185    /// [`every_output_item_serializes_the_same_from_memory_as_from_storage`] measures its cases
1186    /// against. Kept honest by [`output_item_tag`].
1187    const EVERY_OUTPUT_ITEM_TAG: &[&str] = &[
1188        "azure_ai_search_call",
1189        "azure_ai_search_call_output",
1190        "function_call",
1191        "function_call_output",
1192        "message",
1193        "reasoning",
1194    ];
1195
1196    /// The tag one item serializes as. Matched exhaustively, so an added [`OutputItem`] variant
1197    /// fails to compile here instead of quietly going missing from [`EVERY_OUTPUT_ITEM_TAG`].
1198    fn output_item_tag(item: &OutputItem) -> &'static str {
1199        match item {
1200            OutputItem::Message { .. } => "message",
1201            OutputItem::Reasoning { .. } => "reasoning",
1202            OutputItem::AzureAiSearchCall { .. } => "azure_ai_search_call",
1203            OutputItem::AzureAiSearchCallOutput { .. } => "azure_ai_search_call_output",
1204            OutputItem::FunctionCall { .. } => "function_call",
1205            OutputItem::FunctionCallOutput { .. } => "function_call_output",
1206        }
1207    }
1208
1209    fn output_text(text: &str) -> MessageContentItem {
1210        MessageContentItem {
1211            text: text.to_string(),
1212        }
1213    }
1214
1215    fn summary_part(text: &str) -> ReasoningOutput {
1216        ReasoningOutput {
1217            output_type: "summary_text".to_string(),
1218            text: text.to_string(),
1219        }
1220    }
1221
1222    /// One case per shape a round can hand on, `OutputItem` variant by variant. The shapes that
1223    /// break the invariant below most easily are in here on purpose: a multi-part reasoning summary,
1224    /// a refusal that arrives as a content part, and arguments a row keeps as a JSON string.
1225    fn round_trip_cases() -> Vec<APIOutputMessage> {
1226        [
1227            OutputItem::Message {
1228                response_id: "resp_1".to_string(),
1229                role: MessageRole::Assistant,
1230                content: MessageContent::Text("Here you go.".to_string()),
1231            },
1232            OutputItem::Message {
1233                response_id: "resp_1".to_string(),
1234                role: MessageRole::Assistant,
1235                content: MessageContent::OutputText(vec![
1236                    output_text("First part. "),
1237                    output_text("Second part."),
1238                ]),
1239            },
1240            OutputItem::Message {
1241                response_id: "resp_1".to_string(),
1242                role: MessageRole::Assistant,
1243                content: MessageContent::Refusal(vec![RefusalContentItem {
1244                    refusal: "I cannot help with that.".to_string(),
1245                }]),
1246            },
1247            OutputItem::Reasoning {
1248                response_id: "resp_1".to_string(),
1249                id: "rs_1".to_string(),
1250                summary: vec![summary_part("First part."), summary_part("Second part.")],
1251                encrypted_content: Some("payload".to_string()),
1252            },
1253            OutputItem::Reasoning {
1254                response_id: "resp_1".to_string(),
1255                id: "rs_2".to_string(),
1256                summary: vec![],
1257                encrypted_content: None,
1258            },
1259            OutputItem::FunctionCall {
1260                response_id: "resp_1".to_string(),
1261                call_id: "call_1".to_string(),
1262                tool_name: "course_progress".to_string(),
1263                arguments: r#"{"query":"loops"}"#.to_string(),
1264            },
1265            OutputItem::FunctionCallOutput {
1266                response_id: "resp_1".to_string(),
1267                call_id: "call_1".to_string(),
1268                output: r#"{"completed":3}"#.to_string(),
1269            },
1270            OutputItem::AzureAiSearchCall {
1271                response_id: "resp_1".to_string(),
1272                call_id: "call_2".to_string(),
1273                arguments: r#"{"query":"loops"}"#.to_string(),
1274            },
1275            OutputItem::AzureAiSearchCallOutput {
1276                response_id: "resp_1".to_string(),
1277                call_id: "call_2".to_string(),
1278                output: r#"{"documents":[]}"#.to_string(),
1279            },
1280        ]
1281        .into_iter()
1282        .map(|message_type| APIOutputMessage { message_type })
1283        .collect()
1284    }
1285
1286    /// An item goes back to Azure twice: from memory during the round that produced it, and from
1287    /// storage on every turn after. Azure's prompt cache matches an exact prefix, so the two
1288    /// spellings have to be byte-identical or the conversation misses the cache from that item
1289    /// onwards and the model is shown something other than what it wrote.
1290    #[test]
1291    fn every_output_item_serializes_the_same_from_memory_as_from_storage() {
1292        let cases = round_trip_cases();
1293
1294        let mut covered: Vec<&str> = cases
1295            .iter()
1296            .map(|case| output_item_tag(&case.message_type))
1297            .collect();
1298        covered.sort_unstable();
1299        covered.dedup();
1300        assert_eq!(
1301            covered, EVERY_OUTPUT_ITEM_TAG,
1302            "every OutputItem variant needs a case"
1303        );
1304
1305        for from_azure in cases {
1306            assert_eq!(
1307                serde_json::to_value(&from_azure.message_type).expect("the item serializes")["type"],
1308                serde_json::json!(output_item_tag(&from_azure.message_type)),
1309            );
1310
1311            let during_the_turn = APIInputMessage::from(from_azure.clone());
1312            let stored = from_azure
1313                .to_chatbot_conversation_message(Uuid::new_v4())
1314                .expect("the item is storable");
1315            let on_a_later_turn = APIInputMessage::try_from(stored).expect("the row converts back");
1316
1317            assert_eq!(
1318                serde_json::to_string(&during_the_turn).expect("the in-memory item serializes"),
1319                serde_json::to_string(&on_a_later_turn).expect("the stored item serializes"),
1320                "{:?}",
1321                from_azure.message_type,
1322            );
1323        }
1324    }
1325
1326    /// Pins the JSON sent to Azure, not the Rust value, so that adding fields to the shared schema
1327    /// types cannot change what the features built on this ask the LLM for.
1328    #[test]
1329    fn a_string_list_response_format_is_the_schema_azure_expects() {
1330        let serialized = serde_json::to_value(string_list_response_format(
1331            "AFeatureResponse",
1332            "suggestions",
1333        ))
1334        .expect("the response format serializes");
1335        assert_eq!(
1336            serialized,
1337            serde_json::json!({
1338                "type": "json_schema",
1339                "name": "AFeatureResponse",
1340                "schema": {
1341                    "type": "object",
1342                    "properties": {
1343                        "suggestions": {
1344                            "type": "array",
1345                            "items": { "type": "string" }
1346                        }
1347                    },
1348                    "required": ["suggestions"],
1349                    "additionalProperties": false
1350                },
1351                "strict": true
1352            })
1353        );
1354    }
1355
1356    /// Untagged unit variants serialize as `null`, which Azure reads as a request for no summary,
1357    /// so this enum must not be untagged.
1358    #[test]
1359    fn the_reasoning_summary_type_serializes_as_the_name_azure_expects() {
1360        assert_eq!(
1361            serde_json::to_string(&SummaryType::Detailed).expect("the summary type serializes"),
1362            r#""detailed""#
1363        );
1364    }
1365
1366    const CLIENT_TOOL_NAME: &str = <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME;
1367
1368    /// Which calls suspend a turn is decided from the tool name, so a stored call cannot end up
1369    /// with a kind the engine disagrees with.
1370    #[test]
1371    fn a_stored_tool_call_gets_its_kind_from_the_tool_name() {
1372        for (tool_name, expected) in [
1373            (CLIENT_TOOL_NAME, ToolKind::ClientTool),
1374            ("course_structure", ToolKind::Function),
1375        ] {
1376            let message = APIOutputMessage {
1377                message_type: OutputItem::FunctionCall {
1378                    response_id: "resp_1".to_string(),
1379                    call_id: "call_1".to_string(),
1380                    tool_name: tool_name.to_string(),
1381                    arguments: "{}".to_string(),
1382                },
1383            }
1384            .to_chatbot_conversation_message(Uuid::new_v4())
1385            .expect("the call converts to a conversation message");
1386
1387            let Message::ToolCall(call) = message.message else {
1388                panic!("expected a tool call message");
1389            };
1390            assert_eq!(call.tool_kind, expected, "{tool_name}");
1391        }
1392    }
1393
1394    /// A client tool call is an ordinary function call to Azure: which of the two ends answers it
1395    /// is our business, not the provider's, so a resumed turn replays the proven shape.
1396    #[test]
1397    fn a_client_tool_call_and_its_answer_go_back_as_function_items() {
1398        let call = ChatbotConversationMessage {
1399            message: Message::ToolCall(ChatbotConversationMessageToolCall {
1400                tool_name: CLIENT_TOOL_NAME.to_string(),
1401                tool_call_id: "call_1".to_string(),
1402                tool_kind: ToolKind::ClientTool,
1403                response_id: "resp_1".to_string(),
1404                ..Default::default()
1405            }),
1406            ..Default::default()
1407        };
1408        match APIInputMessage::try_from(call)
1409            .expect("the call converts")
1410            .message_type
1411        {
1412            InputItem::FunctionCall {
1413                call_id, tool_name, ..
1414            } => {
1415                assert_eq!(call_id, "call_1");
1416                assert_eq!(tool_name, CLIENT_TOOL_NAME);
1417            }
1418            other => panic!("expected a function call, got {other:?}"),
1419        }
1420
1421        let answer = ChatbotConversationMessage {
1422            message: Message::ToolOutput(ChatbotConversationMessageToolOutput {
1423                output: "the client answered".to_string(),
1424                tool_call_id: "call_1".to_string(),
1425                tool_kind: ToolKind::ClientTool,
1426                response_id: "resp_1".to_string(),
1427                ..Default::default()
1428            }),
1429            ..Default::default()
1430        };
1431        match APIInputMessage::try_from(answer)
1432            .expect("the answer converts")
1433            .message_type
1434        {
1435            InputItem::FunctionCallOutput { call_id, output } => {
1436                assert_eq!(call_id, "call_1");
1437                assert_eq!(output, "the client answered");
1438            }
1439            other => panic!("expected a function call output, got {other:?}"),
1440        }
1441    }
1442
1443    #[test]
1444    fn test_estimate_tokens() {
1445        // The real number is 4
1446        assert_eq!(estimate_tokens("Hello, world!"), 3);
1447        assert_eq!(estimate_tokens(""), 0);
1448        // The real number is 9
1449        assert_eq!(
1450            estimate_tokens("This is a longer sentence with several words."),
1451            11
1452        );
1453        // The real number is 7
1454        assert_eq!(estimate_tokens("Hyvää päivää!"), 7);
1455        // The real number is 9
1456        assert_eq!(estimate_tokens("トークンは楽しい"), 12);
1457        // The real number is 52
1458        assert_eq!(
1459            estimate_tokens("🙂🙃😀😃😄😁😆😅😂🤣😊😇🙂🙃😀😃😄😁😆😅😂🤣😊😇"),
1460            48
1461        );
1462        // The real number is 18
1463        assert_eq!(estimate_tokens("ฉันใช้โทเค็นทุกวัน"), 27);
1464        // The real number is 17
1465        assert_eq!(estimate_tokens("Жетони роблять мене щасливим"), 25);
1466    }
1467}