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
36pub 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
48pub 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
63pub 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#[derive(Serialize, Deserialize, Debug, Clone)]
81pub struct APIOutputMessage {
82 #[serde(flatten)]
83 pub message_type: OutputItem,
84}
85
86#[derive(Serialize, Deserialize, Debug, Clone)]
88pub struct APIInputMessage {
89 #[serde(flatten)]
90 pub message_type: InputItem,
91}
92
93fn 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
108fn 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
118fn 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 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 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 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#[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
554fn 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#[derive(Serialize, Deserialize, Debug)]
571pub struct AzureCompletionRequest {
572 #[serde(flatten)]
573 pub base: LLMRequest,
574 pub stream: bool,
575 pub store: StoreDisabled,
576 #[serde(skip_serializing_if = "Option::is_none")]
578 pub include: Option<Vec<String>>,
579}
580
581#[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#[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
606pub 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#[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 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
646pub 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
659pub(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#[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 len *= 2;
703 }
704 if c.is_ascii_punctuation() {
705 len *= 2;
707 }
708 acc + len
709 });
710 let estimated_tokens = text_length / 4;
712 trace!("Estimated {} tokens for text", estimated_tokens);
713 estimated_tokens
714}
715
716#[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
756fn 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 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 Err(_) => chatbot_err!(
783 FailedAzureResponse,
784 format!(
785 "Error calling LLM API: Status: {}. Error: {}",
786 status, &error_text
787 )
788 ),
789 }
790}
791
792#[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 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#[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#[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
910pub 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
933pub 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 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 context: Some(ReasoningContext::CurrentTurn),
1009 }),
1010 })
1011 }
1012 ModelType::GPTThinking => {
1013 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 context: None,
1032 }),
1033 })
1034 }
1035 ModelType::Mistral => LLMRequestParams::Mistral(MistralParams { placeholder: true }),
1036 }
1037}
1038
1039pub 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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 assert_eq!(estimate_tokens("Hello, world!"), 3);
1447 assert_eq!(estimate_tokens(""), 0);
1448 assert_eq!(
1450 estimate_tokens("This is a longer sentence with several words."),
1451 11
1452 );
1453 assert_eq!(estimate_tokens("Hyvää päivää!"), 7);
1455 assert_eq!(estimate_tokens("トークンは楽しい"), 12);
1457 assert_eq!(
1459 estimate_tokens("🙂🙃😀😃😄😁😆😅😂🤣😊😇🙂🙃😀😃😄😁😆😅😂🤣😊😇"),
1460 48
1461 );
1462 assert_eq!(estimate_tokens("ฉันใช้โทเค็นทุกวัน"), 27);
1464 assert_eq!(estimate_tokens("Жетони роблять мене щасливим"), 25);
1466 }
1467}